Compare commits
1 Commits
main
...
fix/limeng
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b089cd6bf |
@ -1,280 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Common\Company;
|
||||
use App\Support\CourierQuoteStructure;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
|
||||
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
|
||||
*/
|
||||
class ImportCourierQuoteBillingRules extends Command
|
||||
{
|
||||
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
|
||||
{--company= : 报价所属公司ID}
|
||||
{--dry-run : 只校验并输出结果,不写入}
|
||||
{--force : 覆盖已有且内容不同的计费规则}';
|
||||
|
||||
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
|
||||
|
||||
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
|
||||
$this->error('请指定有效的 --company。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
|
||||
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
|
||||
if (! is_array($items) || $items === []) {
|
||||
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$plan = [];
|
||||
$report = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
$report[] = ['', '', '错误', '版本条目必须是对象'];
|
||||
continue;
|
||||
}
|
||||
$code = (string) ($item['version_id'] ?? '');
|
||||
if (isset($seen[$code])) {
|
||||
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
|
||||
continue;
|
||||
}
|
||||
$seen[$code] = true;
|
||||
[$status, $note, $data] = $this->prepare($companyId, $item);
|
||||
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
|
||||
if ($data !== null) {
|
||||
$plan[] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(['版本', '快递公司', '结果', '说明'], $report);
|
||||
$counts = array_count_values(array_column($report, 2));
|
||||
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
|
||||
|
||||
if (isset($counts['错误'])) {
|
||||
$this->error('存在错误,整批未写入:' . $summary);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('校验通过,未写入(dry-run):' . $summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($plan): void {
|
||||
foreach ($plan as [$version, $structure, $rows]) {
|
||||
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
|
||||
if (! $current || $current->company_name !== $version->company_name
|
||||
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|
||||
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
|
||||
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
|
||||
}
|
||||
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info('导入完成:' . $summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array{0:string,1:string,2:?array}
|
||||
*/
|
||||
private function prepare(int $companyId, array $item): array
|
||||
{
|
||||
$code = (string) ($item['version_id'] ?? '');
|
||||
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
|
||||
if ($version === null) {
|
||||
return ['错误', '系统中找不到该版本号', null];
|
||||
}
|
||||
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
|
||||
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
|
||||
}
|
||||
if (($item['conversion'] ?? '') === 'manual') {
|
||||
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
|
||||
}
|
||||
if (($error = $this->inputError($item)) !== null) {
|
||||
return ['错误', $error, null];
|
||||
}
|
||||
|
||||
$source = (array) ($item['structure'] ?? []);
|
||||
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
|
||||
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
|
||||
if (! in_array($mode, self::MODES, true)) {
|
||||
return ['错误', '不支持的计费方式:' . $mode, null];
|
||||
}
|
||||
|
||||
$params = (array) ($item['params'] ?? []);
|
||||
$perTicket = 0.0;
|
||||
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
|
||||
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
|
||||
$perTicket += (float) $surcharge['amount'];
|
||||
}
|
||||
}
|
||||
$structure = [
|
||||
'mode' => $mode,
|
||||
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
|
||||
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
|
||||
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
|
||||
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
|
||||
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
|
||||
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
|
||||
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
|
||||
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
|
||||
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
|
||||
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
|
||||
'imported_from' => 'stock-quote-billing-import',
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
|
||||
$region = trim((string) ($row['region'] ?? ''));
|
||||
if ($region === '') {
|
||||
return ['错误', '存在没有地区名的行', null];
|
||||
}
|
||||
if ($segments) {
|
||||
$list = [];
|
||||
foreach ((array) ($row['segments'] ?? []) as $s) {
|
||||
$list[] = array_filter([
|
||||
'lo' => (float) ($s['lo'] ?? 0),
|
||||
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
|
||||
'type' => (string) ($s['type'] ?? ''),
|
||||
'price' => $this->number($s['price'] ?? null),
|
||||
'base' => $this->number($s['base'] ?? null),
|
||||
'rate' => $this->number($s['rate'] ?? null),
|
||||
'fw' => $this->number($s['fw'] ?? null),
|
||||
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
$rows[] = ['region' => $region, 'segments' => $list];
|
||||
} else {
|
||||
$rows[] = [
|
||||
'region' => $region,
|
||||
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
|
||||
'base' => $this->number($row['base'] ?? null),
|
||||
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
return ['错误', '没有任何地区价格', null];
|
||||
}
|
||||
|
||||
$existing = $version->billing_structure;
|
||||
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
|
||||
if (is_array($existing) && $existing !== []) {
|
||||
if ($existing == $structure && $version->billing_rows == $rows) {
|
||||
return ['跳过', '已导入且内容相同', null];
|
||||
}
|
||||
if (! $this->option('force')) {
|
||||
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
|
||||
}
|
||||
|
||||
return ['覆盖', $note, [$version, $structure, $rows]];
|
||||
}
|
||||
|
||||
return ['写入', $note, [$version, $structure, $rows]];
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
return is_numeric($value) ? round((float) $value, 4) : null;
|
||||
}
|
||||
|
||||
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
|
||||
private function inputError(array $item): ?string
|
||||
{
|
||||
$structure = $item['structure'] ?? null;
|
||||
$params = $item['params'] ?? [];
|
||||
if (! is_array($structure) || ! is_array($params)) {
|
||||
return 'structure 和 params 必须是对象';
|
||||
}
|
||||
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
|
||||
return '不支持的重量取整方式';
|
||||
}
|
||||
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
|
||||
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
|
||||
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
|
||||
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
|
||||
}
|
||||
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
|
||||
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
|
||||
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
|
||||
foreach (['flat_bands', 'rate_tiers'] as $field) {
|
||||
$previous = 0;
|
||||
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
|
||||
foreach ($structure[$field] ?? [] as $edge) {
|
||||
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
|
||||
$previous = (float) $edge;
|
||||
}
|
||||
}
|
||||
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
|
||||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
|
||||
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
|
||||
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
|
||||
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
|
||||
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
|
||||
foreach ($params['surcharges'] ?? [] as $fee) {
|
||||
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
|
||||
}
|
||||
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
|
||||
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
|
||||
$regions = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
|
||||
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
|
||||
if (isset($regions[$region])) return '存在重复地区行';
|
||||
$regions[$region] = true;
|
||||
if ($mode === CourierQuoteStructure::SEGMENTS) {
|
||||
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
|
||||
$previous = 0.0;
|
||||
foreach ($row['segments'] as $segment) {
|
||||
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|
||||
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
|
||||
$hi = $segment['hi'] ?? null;
|
||||
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
|
||||
$previous = $hi === null ? INF : (float) $hi;
|
||||
foreach (['price', 'base', 'rate', 'fw'] as $key) {
|
||||
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
|
||||
}
|
||||
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
|
||||
}
|
||||
} else {
|
||||
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
|
||||
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|
||||
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
|
||||
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
|
||||
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<float> */
|
||||
private function numbers(mixed $values): array
|
||||
{
|
||||
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
|
||||
}
|
||||
}
|
||||
@ -6,14 +6,12 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
|
||||
use Throwable;
|
||||
|
||||
class SyncJushuitanCostPriceHistory extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
||||
{--full : 强制启动或继续首次全量同步}
|
||||
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
|
||||
{--page-size= : 聚水潭接口每页读取条数,最大 50}
|
||||
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
||||
|
||||
@ -27,25 +25,6 @@ class SyncJushuitanCostPriceHistory extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('source') === 'snapshot') {
|
||||
try {
|
||||
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
|
||||
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
|
||||
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
|
||||
);
|
||||
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
|
||||
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($this->option('source') !== 'open_api') {
|
||||
$this->error('source 仅支持 open_api 或 snapshot。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$state = $mirror->state();
|
||||
$full = $this->shouldRunFull($state);
|
||||
|
||||
@ -23,9 +23,9 @@ use Throwable;
|
||||
*
|
||||
* 口径(已与业务确认):
|
||||
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
|
||||
* 再按「店铺 + 完整线上单号」汇总收入金额与收款笔数
|
||||
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
|
||||
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
|
||||
* - 单号匹配:优先完整线上单号精确匹配;仅当核心号唯一时允许兼容回退,多个完整单号共用核心号时禁止合并
|
||||
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
|
||||
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
|
||||
* - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次
|
||||
*/
|
||||
@ -131,11 +131,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'退款',
|
||||
];
|
||||
|
||||
/** 这些收款账户不进入业绩明细,收、支都不取。 */
|
||||
private const EXCLUDED_TARGET_SOURCES = [
|
||||
'jd-wallet',
|
||||
];
|
||||
|
||||
private const TARGET_TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
private const STATUS_MATCHED = '已匹配';
|
||||
@ -237,24 +232,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$end = $start->copy()->addMonthNoOverflow();
|
||||
$reportMonth = $start->toDateString();
|
||||
|
||||
if (in_array($platform, self::EXCLUDED_TARGET_SOURCES, true)) {
|
||||
$deleted = 0;
|
||||
if (! (bool) $this->option('dry-run')) {
|
||||
$deleted = DB::connection($connection)->table(self::TARGET_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', (string) $source['label'])
|
||||
->delete();
|
||||
}
|
||||
$this->warn(sprintf(
|
||||
'%s 已从业绩明细排除,%s 删除镜像行 %d 行(收、支均不取)。',
|
||||
(string) $source['label'],
|
||||
$month,
|
||||
$deleted,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'账期 %s(%s ~ %s),收款账户 %s(%s)',
|
||||
$month,
|
||||
@ -365,8 +342,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
// 种菜与备注映射先用正常连接读取,账单游标使用独立的不缓冲连接。
|
||||
$plantingCores = $this->plantingOrderCores();
|
||||
$plantingReimbursementCores = $this->plantingReimbursementCores();
|
||||
$plantingUnreimbursedCores = $this->plantingUnreimbursedCores();
|
||||
$remarkMap = app(AccountLedgerRemarkMap::class);
|
||||
$tabPlatform = $remarkMap->defaultPlatform($platform);
|
||||
[
|
||||
@ -378,7 +353,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
] = $this->aggregateBills(
|
||||
$this->streamBillRows($billQuery->orderBy($timeColumn)), $source,
|
||||
$orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn,
|
||||
$plantingCores, $plantingReimbursementCores, $plantingUnreimbursedCores, $remarkMap, $tabPlatform,
|
||||
$plantingCores, $remarkMap, $tabPlatform,
|
||||
);
|
||||
if ($billCount === 0) {
|
||||
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
|
||||
@ -387,10 +362,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
$plantingTotal = 0.0;
|
||||
$plantingRows = 0;
|
||||
$plantingReimbursementTotal = 0.0;
|
||||
$plantingReimbursementRows = 0;
|
||||
$plantingUnreimbursedTotal = 0.0;
|
||||
$plantingUnreimbursedRows = 0;
|
||||
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
|
||||
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 2));
|
||||
foreach ($groups as $group) {
|
||||
@ -398,14 +369,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$plantingRows++;
|
||||
$plantingTotal += (float) $group['income_amount'];
|
||||
}
|
||||
if (! empty($group['planting_reimbursement_seen'])) {
|
||||
$plantingReimbursementRows++;
|
||||
$plantingReimbursementTotal += (float) $group['income_amount'];
|
||||
}
|
||||
if (! empty($group['planting_unreimbursed_seen'])) {
|
||||
$plantingUnreimbursedRows++;
|
||||
$plantingUnreimbursedTotal += (float) $group['income_amount'];
|
||||
}
|
||||
}
|
||||
$this->line(sprintf(
|
||||
' 种菜金额:%d 行(识别到 %d 个种菜订单号),合计 %s',
|
||||
@ -413,16 +376,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
count($plantingCores),
|
||||
number_format($plantingTotal, 2),
|
||||
));
|
||||
$this->line(sprintf(
|
||||
' 种菜报销:%d 行(按种菜明细表订单号),合计 %s',
|
||||
$plantingReimbursementRows,
|
||||
number_format($plantingReimbursementTotal, 2),
|
||||
));
|
||||
$this->line(sprintf(
|
||||
' 种菜未报销:%d 行(按聚水潭种菜标签),合计 %s',
|
||||
$plantingUnreimbursedRows,
|
||||
number_format($plantingUnreimbursedTotal, 2),
|
||||
));
|
||||
$this->line(sprintf(
|
||||
' 已挂订单行:优惠 %s / 冲减 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)',
|
||||
number_format($attachedDiscount, 2),
|
||||
@ -448,17 +401,15 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 完整线上单号」聚合的商品金额(商品金额求和) */
|
||||
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
|
||||
$orderAmount = [];
|
||||
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 完整线上单号」(含 0 元行) */
|
||||
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
|
||||
$orderSeen = [];
|
||||
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
|
||||
$coresByCore = [];
|
||||
/** @var array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号 */
|
||||
$identitiesByCore = [];
|
||||
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
|
||||
$coreSeen = [];
|
||||
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 完整线上单号) */
|
||||
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 核心单号) */
|
||||
$orderVirtualPaid = [];
|
||||
// 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。
|
||||
// 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号,
|
||||
@ -577,21 +528,19 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
|
||||
|
||||
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 完整线上单号) */
|
||||
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
|
||||
$orderPaid = [];
|
||||
/** @var array<string,float> $orderPaidAlt 订单侧「卖家实收」金额(双口径兜底用) */
|
||||
$orderPaidAlt = [];
|
||||
/** @var array<string,float> $orderPaidPay 订单侧「应付」金额(实收缺失时的退化口径) */
|
||||
$orderPaidPay = [];
|
||||
foreach ($paidRows as $row) {
|
||||
$rawNo = (string) $row['so_id'];
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$identity = self::orderIdentity($rawNo);
|
||||
$core = self::cleanOrderNo((string) $row['so_id']);
|
||||
if ($core === '') {
|
||||
continue;
|
||||
}
|
||||
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
|
||||
$key = $shopKey . '|' . $identity;
|
||||
$key = $shopKey . '|' . $core;
|
||||
$orderPaid[$key] = ($orderPaid[$key] ?? 0.0) + (float) $row['paid'];
|
||||
$orderPaidAlt[$key] = ($orderPaidAlt[$key] ?? 0.0) + (float) ($row['paid_alt'] ?? 0.0);
|
||||
$orderPaidPay[$key] = ($orderPaidPay[$key] ?? 0.0) + (float) ($row['paid_pay'] ?? 0.0);
|
||||
@ -600,7 +549,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 商品金额数组仍只由商品明细构建:无明细时商品金额保持为空、按口径标「订单无商品金额」。
|
||||
$orderSeen[$key] = true;
|
||||
$coreSeen[$core] = true;
|
||||
$identitiesByCore[$core][$shopKey][$identity] = true;
|
||||
if ($shopKey !== '') {
|
||||
$coresByCore[$core][$shopKey] = true;
|
||||
}
|
||||
@ -608,17 +556,14 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
unset($paidRows, $row);
|
||||
foreach ($orderRows as $row) {
|
||||
$rawNo = (string) $row['so_id'];
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$identity = self::orderIdentity($rawNo);
|
||||
$core = self::cleanOrderNo((string) $row['so_id']);
|
||||
if ($core === '') {
|
||||
continue;
|
||||
}
|
||||
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
|
||||
$key = $shopKey . '|' . $identity;
|
||||
$key = $shopKey . '|' . $core;
|
||||
$orderSeen[$key] = true;
|
||||
$coreSeen[$core] = true;
|
||||
$identitiesByCore[$core][$shopKey][$identity] = true;
|
||||
if ($shopKey !== '') {
|
||||
$coresByCore[$core][$shopKey] = true;
|
||||
}
|
||||
@ -673,25 +618,21 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 两个口径各跑一遍同一条取数链(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底):
|
||||
// 商品金额用于「商品金额」列,订单实付用于「订单实付金额」列与比对基准。
|
||||
[$productStatus, $productAmount, $productRemark] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderAmount,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_AMOUNT,
|
||||
);
|
||||
[$paidStatus, $paidAmount, $paidRemark] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderPaidNet,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_PAID,
|
||||
);
|
||||
|
||||
@ -718,14 +659,12 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 「先备注规则、后口径兜底」的顺序判定),避免把有明确原因的行悄悄抹平。
|
||||
{
|
||||
[$altStatus, $altAmount] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderPaidAlt,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_PAID,
|
||||
);
|
||||
$altMatched = $altStatus === self::STATUS_MATCHED && $altAmount !== null;
|
||||
@ -736,14 +675,12 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 或主单没写实收而子单写了值的情况都覆盖到)。
|
||||
if (! ($altMatched && abs($income + $promoAmount - $altValue) <= 0.02)) {
|
||||
[$payStatus, $payValue] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderPaidPay,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_PAID,
|
||||
);
|
||||
if ($payStatus === self::STATUS_MATCHED && $payValue !== null) {
|
||||
@ -780,12 +717,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$buffer[] = [
|
||||
'report_month' => $reportMonth,
|
||||
'platform' => $group['platform'],
|
||||
'row_key' => self::groupRowKey(
|
||||
$reportMonth,
|
||||
(string) $group['platform'],
|
||||
(string) $group['shop_key'],
|
||||
(string) $group['order_identity']
|
||||
),
|
||||
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
|
||||
'occurred_date' => $group['occurred_date'],
|
||||
'store_name' => $group['store_name'],
|
||||
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
|
||||
@ -796,8 +728,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
|
||||
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
|
||||
'planting_amount' => ! empty($group['planting_seen']) ? $income : null,
|
||||
'planting_reimbursement_amount' => ! empty($group['planting_reimbursement_seen']) ? $income : null,
|
||||
'planting_unreimbursed_amount' => ! empty($group['planting_unreimbursed_seen']) ? $income : null,
|
||||
'product_amount' => $productAmount,
|
||||
'paid_amount' => $paidAmount,
|
||||
'difference' => $difference,
|
||||
@ -913,7 +843,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
|
||||
/**
|
||||
* 只回填「种菜金额 / 种菜报销 / 种菜未报销」:不读账单,直接按订单号判断,是就写入收入金额。
|
||||
* 只回填「种菜金额」:不读账单,直接按业绩明细里已有的「订单号」判断是不是种菜订单,
|
||||
* 是就把该行的收入金额写进去,不是就清空。适合给已经同步好的账期补这一列。
|
||||
*/
|
||||
private function backfillPlantingAmounts(string $reportMonth): int
|
||||
{
|
||||
@ -923,8 +854,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$cores = $this->plantingOrderCores();
|
||||
$reimbursementCores = $this->plantingReimbursementCores();
|
||||
$unreimbursedCores = $this->plantingUnreimbursedCores();
|
||||
if ($cores === []) {
|
||||
$this->warn('没有识别到任何种菜订单号(种菜明细表与聚水潭订单标签明细的交集为空),本列将全部为空。');
|
||||
}
|
||||
@ -933,56 +862,30 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
->where('report_month', $reportMonth);
|
||||
|
||||
$rows = $target()->get(['row_key', 'order_core_no', 'income_amount']);
|
||||
$target()->update([
|
||||
'planting_amount' => null,
|
||||
'planting_reimbursement_amount' => null,
|
||||
'planting_unreimbursed_amount' => null,
|
||||
]);
|
||||
$target()->update(['planting_amount' => null]);
|
||||
|
||||
$values = [];
|
||||
$total = 0.0;
|
||||
$reimbursementValues = [];
|
||||
$reimbursementTotal = 0.0;
|
||||
$unreimbursedValues = [];
|
||||
$unreimbursedTotal = 0.0;
|
||||
foreach ($rows as $row) {
|
||||
$core = self::orderCoreNo((string) $row->order_core_no);
|
||||
if ($core === '') {
|
||||
if ($core === '' || ! isset($cores[$core])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = round((float) $row->income_amount, 2);
|
||||
if (isset($cores[$core])) {
|
||||
$values[(string) $row->row_key] = $amount;
|
||||
$total += $amount;
|
||||
}
|
||||
if (isset($reimbursementCores[$core])) {
|
||||
$reimbursementValues[(string) $row->row_key] = $amount;
|
||||
$reimbursementTotal += $amount;
|
||||
}
|
||||
if (isset($unreimbursedCores[$core])) {
|
||||
$unreimbursedValues[(string) $row->row_key] = $amount;
|
||||
$unreimbursedTotal += $amount;
|
||||
}
|
||||
$values[(string) $row->row_key] = $amount;
|
||||
$total += $amount;
|
||||
}
|
||||
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $values);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'planting_reimbursement_amount', $reimbursementValues);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'planting_unreimbursed_amount', $unreimbursedValues);
|
||||
|
||||
$this->info(sprintf(
|
||||
'已回填 %s:种菜金额 %d / %d 行,合计 %s(识别 %d 个种菜订单号);种菜报销 %d 行,合计 %s(识别 %d 个种菜明细订单号);种菜未报销 %d 行,合计 %s(识别 %d 个聚水潭种菜标签订单号)。',
|
||||
'已回填 %s 种菜金额:%d / %d 行,合计 %s(共识别 %d 个种菜订单号)。',
|
||||
$reportMonth,
|
||||
count($values),
|
||||
count($rows),
|
||||
number_format($total, 2),
|
||||
count($cores),
|
||||
count($reimbursementValues),
|
||||
number_format($reimbursementTotal, 2),
|
||||
count($reimbursementCores),
|
||||
count($unreimbursedValues),
|
||||
number_format($unreimbursedTotal, 2),
|
||||
count($unreimbursedCores),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
@ -1017,37 +920,18 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$target()
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', $platform)
|
||||
->update([
|
||||
'discount_amount' => null,
|
||||
'offset_amount' => null,
|
||||
'refund_amount' => null,
|
||||
'planting_amount' => null,
|
||||
'planting_reimbursement_amount' => null,
|
||||
'planting_unreimbursed_amount' => null,
|
||||
]);
|
||||
->update(['discount_amount' => null, 'offset_amount' => null, 'refund_amount' => null, 'planting_amount' => null]);
|
||||
|
||||
$discounts = [];
|
||||
$offsets = [];
|
||||
$refunds = [];
|
||||
$plantings = [];
|
||||
$plantingReimbursements = [];
|
||||
$plantingUnreimbursed = [];
|
||||
foreach ($groups as $group) {
|
||||
if (empty($group['discount_seen'])
|
||||
&& empty($group['offset_seen'])
|
||||
&& empty($group['refund_seen'])
|
||||
&& empty($group['planting_seen'])
|
||||
&& empty($group['planting_reimbursement_seen'])
|
||||
&& empty($group['planting_unreimbursed_seen'])) {
|
||||
if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['refund_seen']) && empty($group['planting_seen'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowKey = self::groupRowKey(
|
||||
$reportMonth,
|
||||
$platform,
|
||||
(string) $group['shop_key'],
|
||||
(string) $group['order_identity']
|
||||
);
|
||||
$rowKey = md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $group['order_core_no']);
|
||||
if (! isset($existing[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
@ -1064,20 +948,12 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
if (! empty($group['planting_seen'])) {
|
||||
$plantings[$rowKey] = round((float) $group['income_amount'], 2);
|
||||
}
|
||||
if (! empty($group['planting_reimbursement_seen'])) {
|
||||
$plantingReimbursements[$rowKey] = round((float) $group['income_amount'], 2);
|
||||
}
|
||||
if (! empty($group['planting_unreimbursed_seen'])) {
|
||||
$plantingUnreimbursed[$rowKey] = round((float) $group['income_amount'], 2);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'discount_amount', $discounts);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'refund_amount', $refunds);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $plantings);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'planting_reimbursement_amount', $plantingReimbursements);
|
||||
$this->updateAmountColumn($connection, $reportMonth, 'planting_unreimbursed_amount', $plantingUnreimbursed);
|
||||
|
||||
// A 方案:本月本平台的补行(payment_count=0 的行)整体重建,保证幂等。
|
||||
$target()
|
||||
@ -1114,7 +990,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
]);
|
||||
|
||||
$this->info(sprintf(
|
||||
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s)/ 种菜报销 %d 行(%s)/ 种菜未报销 %d 行(%s),镜像原有 %d 行,未重建业绩明细。',
|
||||
'已回填 %s %s:优惠 %d 行(%s)/ 冲减 %d 行(%s)/ 补「无收款账单/无单号」优惠冲减行 %d 行 / 种菜 %d 行(%s),镜像原有 %d 行,未重建业绩明细。',
|
||||
$month,
|
||||
$platform,
|
||||
count($discounts),
|
||||
@ -1124,10 +1000,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$promoRows,
|
||||
count($plantings),
|
||||
number_format(array_sum($plantings), 2),
|
||||
count($plantingReimbursements),
|
||||
number_format(array_sum($plantingReimbursements), 2),
|
||||
count($plantingUnreimbursed),
|
||||
number_format(array_sum($plantingUnreimbursed), 2),
|
||||
count($existing),
|
||||
));
|
||||
|
||||
@ -1165,8 +1037,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
* @param iterable<int,object> $bills
|
||||
* @param array<string,mixed> $source
|
||||
* @param array<string,bool> $plantingCores
|
||||
* @param array<string,bool> $plantingReimbursementCores
|
||||
* @param array<string,bool> $plantingUnreimbursedCores
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function aggregateBills(
|
||||
@ -1178,8 +1048,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
string $expenseColumn,
|
||||
string $realStoreColumn,
|
||||
array $plantingCores,
|
||||
array $plantingReimbursementCores,
|
||||
array $plantingUnreimbursedCores,
|
||||
AccountLedgerRemarkMap $remarkMap,
|
||||
?string $tabPlatform,
|
||||
): array {
|
||||
@ -1230,10 +1098,9 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$identity = self::orderIdentity($rawNo);
|
||||
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
|
||||
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
|
||||
$key = $shopKey . '|' . $identity;
|
||||
$key = $shopKey . '|' . $core;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
@ -1243,7 +1110,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'shop_key' => $shopKey,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'order_identity' => $identity,
|
||||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||||
'payment_count' => 0,
|
||||
'income_amount' => 0.0,
|
||||
@ -1263,14 +1129,13 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
|
||||
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
|
||||
if ($bucket !== 'income') {
|
||||
$promoKey = $identity === '' ? $shopKey . '||' . $billItem : $key;
|
||||
$promoKey = $core === '' ? $shopKey . '||' . $billItem : $key;
|
||||
if (! isset($promoOnlyGroups[$promoKey])) {
|
||||
$promoOnlyGroups[$promoKey] = [
|
||||
'shop_key' => $shopKey,
|
||||
'store_name' => $realStore !== '' ? $realStore : $storeName,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'order_identity' => $identity,
|
||||
'bill_item' => $billItem,
|
||||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||||
'discount_amount' => 0.0,
|
||||
@ -1310,12 +1175,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
if (isset($plantingCores[$core])) {
|
||||
$groups[$key]['planting_seen'] = true;
|
||||
}
|
||||
if (isset($plantingReimbursementCores[$core])) {
|
||||
$groups[$key]['planting_reimbursement_seen'] = true;
|
||||
}
|
||||
if (isset($plantingUnreimbursedCores[$core])) {
|
||||
$groups[$key]['planting_unreimbursed_seen'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」,
|
||||
@ -1337,7 +1196,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
|
||||
foreach ($promoOnlyGroups as $key => $group) {
|
||||
if ($group['order_identity'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_identity']])) {
|
||||
if ($group['order_core_no'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_core_no']])) {
|
||||
unset($promoOnlyGroups[$key]);
|
||||
}
|
||||
}
|
||||
@ -1356,10 +1215,9 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
*/
|
||||
private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array
|
||||
{
|
||||
$identity = (string) ($group['order_identity'] ?? '');
|
||||
$core = (string) $group['order_core_no'];
|
||||
$rowKey = $identity !== ''
|
||||
? self::groupRowKey($reportMonth, $platform, (string) $group['shop_key'], $identity)
|
||||
$rowKey = $core !== ''
|
||||
? md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $core)
|
||||
: md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|promo|' . $group['bill_item']);
|
||||
|
||||
return [
|
||||
@ -1377,8 +1235,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
|
||||
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
|
||||
'planting_amount' => null,
|
||||
'planting_reimbursement_amount' => null,
|
||||
'planting_unreimbursed_amount' => null,
|
||||
'product_amount' => null,
|
||||
'paid_amount' => null,
|
||||
'difference' => null,
|
||||
@ -1433,31 +1289,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return self::orderCoreNo($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整线上单号身份:保留日期前缀,只统一空白、横杠形态和大小写。
|
||||
*
|
||||
* 拼多多的「260627-...」「260713-...」是两个不同订单,不能按去掉
|
||||
* 日期前缀后的核心号合并;完整单号用于账单分组与订单侧精确匹配,
|
||||
* 核心号只在唯一候选时作为兼容回退。
|
||||
*/
|
||||
private static function orderIdentity(string $value): string
|
||||
{
|
||||
$value = str_replace("\u{3000}", ' ', trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', '', $value) ?? '';
|
||||
|
||||
return strtoupper($value);
|
||||
}
|
||||
|
||||
private static function groupRowKey(string $reportMonth, string $platform, string $shopKey, string $identity): string
|
||||
{
|
||||
return md5($reportMonth . '|' . $platform . '|' . $shopKey . '|' . $identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。
|
||||
*
|
||||
@ -1470,7 +1301,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
private function plantingOrderCores(): array
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
$cores = $this->plantingReimbursementCores();
|
||||
$positiveLabels = [];
|
||||
foreach (app(JstOrderLabelPlantingRules::class)->map($companyId) as $label => $flag) {
|
||||
if ($flag === '是') {
|
||||
@ -1478,7 +1308,9 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// 聚水潭订单标签明细里「是否种菜订单=是」的订单号
|
||||
$cores = [];
|
||||
|
||||
// 并集的第一部分:聚水潭订单标签明细里「是否种菜订单=是」的订单号
|
||||
// (命中种菜标签映射,或货号含信誉订单虚拟条码 X00006,与页面同一规则)
|
||||
foreach (app(PlantingOrderLabelLookup::class)->orderNumbers($companyId, $positiveLabels) as $orderNo) {
|
||||
$core = self::orderCoreNo((string) $orderNo);
|
||||
@ -1487,18 +1319,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
}
|
||||
|
||||
return $cores;
|
||||
}
|
||||
|
||||
/**
|
||||
* 种菜报销订单号:只取「种菜明细表」的订单号,不包含聚水潭种菜标签。
|
||||
*
|
||||
* @return array<string,true>
|
||||
*/
|
||||
private function plantingReimbursementCores(): array
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
$cores = [];
|
||||
// 并集的第二部分:种菜明细表里的订单号
|
||||
foreach (DB::table('planting_details')->where('company_id', $companyId)->distinct()->pluck('order_no') as $orderNo) {
|
||||
$core = self::orderCoreNo((string) $orderNo);
|
||||
if ($core !== '') {
|
||||
@ -1509,33 +1330,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return $cores;
|
||||
}
|
||||
|
||||
/**
|
||||
* 种菜未报销订单号:聚水潭订单标签明细里「是否种菜订单=是」,且尚未出现在种菜明细表。
|
||||
*
|
||||
* @return array<string,true>
|
||||
*/
|
||||
private function plantingUnreimbursedCores(): array
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
$reimbursementCores = $this->plantingReimbursementCores();
|
||||
$positiveLabels = [];
|
||||
foreach (app(JstOrderLabelPlantingRules::class)->map($companyId) as $label => $flag) {
|
||||
if ($flag === '是') {
|
||||
$positiveLabels[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$cores = [];
|
||||
foreach (app(PlantingOrderLabelLookup::class)->orderNumbers($companyId, $positiveLabels) as $orderNo) {
|
||||
$core = self::orderCoreNo((string) $orderNo);
|
||||
if ($core !== '' && ! isset($reimbursementCores[$core])) {
|
||||
$cores[$core] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $cores;
|
||||
}
|
||||
|
||||
private static function orderCoreNo(string $value): string
|
||||
{
|
||||
$value = str_replace("\u{3000}", ' ', trim($value));
|
||||
@ -1613,13 +1407,11 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「店铺 + 完整线上单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
|
||||
* 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
|
||||
*
|
||||
* 两个比对基准共用这一条取数链:
|
||||
* 同店完整单号 → 同店核心号唯一时回退 → 跨店唯一时回退 → 空店铺唯一时回退。
|
||||
* 核心号一旦对应多个完整线上单号就视为歧义,不再合并金额,避免不同订单串单。
|
||||
* 同店同号 → 店铺名不一致(订单号唯一)→ 同号多条(多店铺合计)→ 空店铺兜底 → 订单表无此单。
|
||||
*
|
||||
* @param array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号
|
||||
* @param array<string,float> $amounts 订单侧金额集合(商品金额或订单实付)
|
||||
* @param array<string,bool> $orderSeen 订单侧出现过的「店铺|单号」
|
||||
* @param array<string,bool> $coreSeen 订单侧出现过的单号
|
||||
@ -1627,14 +1419,12 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
* @return array{0:string,1:?float,2:string}
|
||||
*/
|
||||
private function matchOrderSide(
|
||||
string $onlineOrderNo,
|
||||
string $core,
|
||||
string $shopKey,
|
||||
array $amounts,
|
||||
array $orderSeen,
|
||||
array $coreSeen,
|
||||
array $coresByCore,
|
||||
array $identitiesByCore,
|
||||
string $noAmountRemark,
|
||||
): array {
|
||||
if ($core === '') {
|
||||
@ -1645,9 +1435,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_BAD_FORMAT];
|
||||
}
|
||||
|
||||
$identity = self::orderIdentity($onlineOrderNo);
|
||||
$exactKey = $shopKey . '|' . $identity;
|
||||
if ($identity !== '' && isset($orderSeen[$exactKey])) {
|
||||
$exactKey = $shopKey . '|' . $core;
|
||||
if (isset($orderSeen[$exactKey])) {
|
||||
if (isset($amounts[$exactKey])) {
|
||||
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
|
||||
|
||||
@ -1658,43 +1447,41 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
|
||||
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
|
||||
$otherKey = array_key_first($coresByCore[$core]) . '|' . $core;
|
||||
if (isset($amounts[$otherKey])) {
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), self::REMARK_SHOP_DIFF];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
|
||||
$sum = 0.0;
|
||||
$hasAmount = false;
|
||||
foreach (array_keys($coresByCore[$core]) as $otherShop) {
|
||||
$key2 = $otherShop . '|' . $core;
|
||||
if (isset($amounts[$key2])) {
|
||||
$sum += (float) $amounts[$key2];
|
||||
$hasAmount = true;
|
||||
}
|
||||
}
|
||||
if ($hasAmount) {
|
||||
return [self::STATUS_MATCHED, round($sum, 2), self::REMARK_DUP_ORDER];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
if (isset($coreSeen[$core])) {
|
||||
$sameShopIdentities = array_keys($identitiesByCore[$core][$shopKey] ?? []);
|
||||
if (count($sameShopIdentities) > 1) {
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
|
||||
}
|
||||
if (count($sameShopIdentities) === 1) {
|
||||
$fallbackKey = $shopKey . '|' . $sameShopIdentities[0];
|
||||
if (isset($amounts[$fallbackKey])) {
|
||||
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
|
||||
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$fallbackKey], 2), $remark];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
|
||||
$blankKey = '|' . $core;
|
||||
if (isset($amounts[$blankKey])) {
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$blankKey], 2), ''];
|
||||
}
|
||||
|
||||
$shops = array_keys($identitiesByCore[$core] ?? []);
|
||||
if (count($shops) === 1) {
|
||||
$otherShop = (string) $shops[0];
|
||||
$otherIdentities = array_keys($identitiesByCore[$core][$otherShop] ?? []);
|
||||
if (count($otherIdentities) > 1) {
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
|
||||
}
|
||||
if (count($otherIdentities) === 1) {
|
||||
$otherKey = $otherShop . '|' . $otherIdentities[0];
|
||||
if (isset($amounts[$otherKey])) {
|
||||
$remark = $otherShop === '' ? '' : self::REMARK_SHOP_DIFF;
|
||||
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), $remark];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
}
|
||||
|
||||
// 同一核心号跨多个店铺或同一店铺多个完整单号:禁止把金额相加。
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_NO_ORDER];
|
||||
|
||||
@ -34,9 +34,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
|
||||
private const VIRTUAL_SKU_PREFIX = 'X0000';
|
||||
|
||||
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
|
||||
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
|
||||
|
||||
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
|
||||
|
||||
private const REMARK_NO_PRICE = '未匹配商品单价';
|
||||
@ -57,9 +54,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
private const PRICE_SOURCE_DING = '线上代发价格-代发单价';
|
||||
|
||||
/** 最后兜底:普通商品资料(operations_replica.rpt.jst_sku)里的成本价。 */
|
||||
private const PRICE_SOURCE_CATALOG = '普通商品资料成本价';
|
||||
|
||||
/** 线上代发价格的采购单来源(与「线上代发价格」页面同一口径)。 */
|
||||
private const PURCHASE_PRICE_TABLE = 'product_purchase_price_mirror_rows';
|
||||
|
||||
@ -120,15 +114,10 @@ class SyncStoreReceiptProductCost extends Command
|
||||
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
|
||||
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
|
||||
// 没有对应的收款商品,不参与商品成本。
|
||||
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
|
||||
$billQuery = $mirror->table(self::SOURCE_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->whereNotNull('income_amount')
|
||||
->where('income_amount', '!=', 0)
|
||||
->where(static function ($query): void {
|
||||
$query->whereNull('planting_amount')
|
||||
->orWhere('planting_amount', '<=', 0);
|
||||
});
|
||||
->where('income_amount', '!=', 0);
|
||||
if ($platform !== '') {
|
||||
$billQuery->where('platform', $platform);
|
||||
}
|
||||
@ -179,7 +168,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
|
||||
$lines = [];
|
||||
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
|
||||
foreach (self::SOURCES as $source) {
|
||||
try {
|
||||
$rows = $mirror->select(
|
||||
@ -193,8 +181,7 @@ class SyncStoreReceiptProductCost extends Command
|
||||
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
|
||||
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
|
||||
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
|
||||
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
|
||||
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})"
|
||||
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
|
||||
@ -249,14 +236,11 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$prices = $this->weightedAverageUnitPrices($mirror, $reportMonth);
|
||||
// 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。
|
||||
$fallbackPrices = $this->onlineDaifaUnitPrices($mirror);
|
||||
// 代发价格也没有时,再用普通商品资料里的商品成本价兜底。
|
||||
$catalogPrices = $this->catalogCostPrices($mirror);
|
||||
$this->info(sprintf(
|
||||
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d',
|
||||
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d',
|
||||
count($shortNames['by_provider']),
|
||||
count($prices),
|
||||
count($fallbackPrices),
|
||||
count($catalogPrices)
|
||||
count($fallbackPrices)
|
||||
));
|
||||
|
||||
$this->pruneTarget($connection, $reportMonth, $platform);
|
||||
@ -270,12 +254,13 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$missingPriceRows = 0;
|
||||
$missingWarehouseRows = 0;
|
||||
$fallbackPriceRows = 0;
|
||||
$catalogPriceRows = 0;
|
||||
$mismatchRows = 0;
|
||||
|
||||
foreach ($billByCore as $core => $bill) {
|
||||
foreach ($this->costLines($lines[$core] ?? []) as $line) {
|
||||
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
|
||||
$short = $shortNames['by_warehouse'][$line['warehouse']]
|
||||
?? $shortNames['by_provider'][$line['warehouse']]
|
||||
?? '';
|
||||
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
|
||||
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
|
||||
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
|
||||
@ -287,15 +272,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$fallbackPriceRows++;
|
||||
}
|
||||
}
|
||||
// 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。
|
||||
if ($price === null) {
|
||||
$catalogPrice = $catalogPrices[$line['sku']] ?? null;
|
||||
if ($catalogPrice !== null) {
|
||||
$price = $catalogPrice;
|
||||
$priceSource = self::PRICE_SOURCE_CATALOG;
|
||||
$catalogPriceRows++;
|
||||
}
|
||||
}
|
||||
$quantity = round($line['qty'], 4);
|
||||
$unitPrice = $price === null ? null : round((float) $price, 2);
|
||||
$cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2);
|
||||
@ -366,10 +342,9 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$this->info(sprintf(' 商品数量合计:%s', number_format($quantityTotal, 2)));
|
||||
$this->info(sprintf(' 商品成本合计:%s', number_format($costTotal, 2)));
|
||||
$this->info(sprintf(
|
||||
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;商品资料成本价兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
|
||||
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
|
||||
$missingWarehouseRows,
|
||||
$fallbackPriceRows,
|
||||
$catalogPriceRows,
|
||||
$missingPriceRows,
|
||||
$mismatchRows
|
||||
));
|
||||
@ -467,14 +442,10 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
$expanded = [];
|
||||
foreach ($components as [$code, $multiplier]) {
|
||||
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
|
||||
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
|
||||
continue;
|
||||
}
|
||||
$expanded[] = [$code, $quantity * $multiplier, true];
|
||||
}
|
||||
|
||||
return $expanded === [] ? [] : $expanded;
|
||||
return $expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -488,6 +459,7 @@ class SyncStoreReceiptProductCost extends Command
|
||||
private function warehouseShortNames(): array
|
||||
{
|
||||
$byProvider = [];
|
||||
$byWarehouse = [];
|
||||
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
|
||||
$provider = trim((string) $mapping->warehouse_provider);
|
||||
@ -496,30 +468,19 @@ class SyncStoreReceiptProductCost extends Command
|
||||
if ($short === '') {
|
||||
continue;
|
||||
}
|
||||
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
|
||||
$byWarehouse[$warehouse] = $short;
|
||||
}
|
||||
if ($provider === '') {
|
||||
continue;
|
||||
}
|
||||
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
|
||||
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
|
||||
$isMain = str_contains($warehouse, '主仓');
|
||||
if ($isMain) {
|
||||
$byProvider[$provider] = $short;
|
||||
continue;
|
||||
}
|
||||
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
|
||||
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
|
||||
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
|
||||
$byProvider[$provider] = $short;
|
||||
continue;
|
||||
}
|
||||
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
|
||||
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
|
||||
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
|
||||
if ($isMain || ! isset($byProvider[$provider])) {
|
||||
$byProvider[$provider] = $short;
|
||||
}
|
||||
}
|
||||
|
||||
return ['by_provider' => $byProvider];
|
||||
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -628,32 +589,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
return $prices;
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通商品资料(operations_replica.rpt.jst_sku → ops_jst_sku)里的商品成本价:
|
||||
* 只取普通商品(sku_type=normal)且成本价大于 0 的记录,按商品编码建索引。
|
||||
*
|
||||
* @return array<string,float>
|
||||
*/
|
||||
private function catalogCostPrices($mirror): array
|
||||
{
|
||||
$prices = [];
|
||||
try {
|
||||
foreach ($mirror->table('ops_jst_sku')
|
||||
->where('sku_type', 'normal')
|
||||
->where('cost_price', '>', 0)
|
||||
->get(['sku_id', 'cost_price']) as $row) {
|
||||
$sku = mb_strtoupper(trim((string) $row->sku_id));
|
||||
if ($sku !== '') {
|
||||
$prices[$sku] = round((float) $row->cost_price, 2);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->warn('普通商品资料成本价读取失败(跳过该兜底来源):' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return $prices;
|
||||
}
|
||||
|
||||
private function pruneTarget(string $connection, string $reportMonth, string $platform): void
|
||||
{
|
||||
$query = DB::connection($connection)->table(self::TARGET_TABLE)->where('report_month', $reportMonth);
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class VerifyErpDailyBillCoverage extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:verify-erp-daily-coverage
|
||||
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
|
||||
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
|
||||
{--json : 输出 JSON}';
|
||||
|
||||
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
|
||||
|
||||
private const TARGETS = [
|
||||
'jd-daily' => [
|
||||
'label' => '京东日账单',
|
||||
'periods' => 'jd_daily_bill_periods',
|
||||
'rows' => 'jd_daily_bill_rows',
|
||||
],
|
||||
'jd-wallet' => [
|
||||
'label' => '京东钱包',
|
||||
'periods' => 'jd_wallet_bill_periods',
|
||||
'rows' => 'jd_wallet_bill_rows',
|
||||
],
|
||||
'douyin' => [
|
||||
'label' => '头条放心购',
|
||||
'periods' => 'douyin_authoritative_periods',
|
||||
'rows' => 'douyin_authoritative_bills',
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$fromText = (string) $this->option('from');
|
||||
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
|
||||
$from = $this->date($fromText);
|
||||
$to = $this->date($toText);
|
||||
|
||||
if ($from === null || $to === null || $from->gt($to)) {
|
||||
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$results = [];
|
||||
$failed = false;
|
||||
|
||||
foreach (self::TARGETS as $key => $target) {
|
||||
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
|
||||
$results[] = [
|
||||
'dataset' => $key,
|
||||
'label' => $target['label'],
|
||||
'month' => null,
|
||||
'required_start' => $from->toDateString(),
|
||||
'required_through' => $to->toDateString(),
|
||||
'imported_start' => null,
|
||||
'imported_end' => null,
|
||||
'row_count' => 0,
|
||||
'registered_rows' => null,
|
||||
'income' => null,
|
||||
'expense' => null,
|
||||
'status' => 'missing_table',
|
||||
'issue' => '镜像表不存在',
|
||||
];
|
||||
$failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
while ($month->lte($to)) {
|
||||
$monthEnd = $month->copy()->endOfMonth();
|
||||
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
|
||||
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
|
||||
$requiredEndExclusive = $requiredThrough->copy()->addDay();
|
||||
$period = $db->table($target['periods'])
|
||||
->where('period_start', $month->toDateString())
|
||||
->first();
|
||||
|
||||
$status = 'ok';
|
||||
$rowCount = 0;
|
||||
$issue = null;
|
||||
$summary = null;
|
||||
|
||||
if ($period === null) {
|
||||
$status = 'missing_month';
|
||||
$issue = '缺少该月份导入记录';
|
||||
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
|
||||
$status = 'partial';
|
||||
$issue = '导入截止日早于要求日期';
|
||||
} else {
|
||||
$summary = $db->table($target['rows'])
|
||||
->where('generation', $period->active_generation)
|
||||
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
|
||||
->first();
|
||||
$rowCount = (int) ($summary->rows_count ?? 0);
|
||||
if ($rowCount === 0) {
|
||||
$status = 'empty';
|
||||
$issue = '有效版本没有明细';
|
||||
} elseif ($rowCount !== (int) $period->row_count) {
|
||||
$status = 'row_count_mismatch';
|
||||
$issue = '有效版本实际行数与期间登记不一致';
|
||||
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|
||||
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
|
||||
$status = 'amount_mismatch';
|
||||
$issue = '有效版本收支合计与期间登记不一致';
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 'ok') {
|
||||
$failed = true;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'dataset' => $key,
|
||||
'label' => $target['label'],
|
||||
'month' => $month->format('Y-m'),
|
||||
'required_start' => $requiredStart->toDateString(),
|
||||
'required_through' => $requiredThrough->toDateString(),
|
||||
'imported_start' => $period?->period_start,
|
||||
'imported_end' => $period?->period_end,
|
||||
'row_count' => $rowCount,
|
||||
'registered_rows' => $period === null ? null : (int) $period->row_count,
|
||||
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
|
||||
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
|
||||
'status' => $status,
|
||||
'issue' => $issue,
|
||||
];
|
||||
|
||||
unset($summary);
|
||||
$month->addMonthNoOverflow()->startOfMonth();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
} else {
|
||||
$this->table(
|
||||
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
|
||||
array_map(static fn (array $row): array => [
|
||||
$row['label'],
|
||||
$row['month'] ?? '—',
|
||||
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
|
||||
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
|
||||
number_format((int) $row['row_count']),
|
||||
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
|
||||
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
|
||||
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
|
||||
$row['status'],
|
||||
$row['issue'] ?? '',
|
||||
], $results),
|
||||
);
|
||||
}
|
||||
|
||||
return $failed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function date(string $value): ?Carbon
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
|
||||
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
@ -105,15 +105,11 @@ class Pages extends Controller
|
||||
'拼多多',
|
||||
'京东账单',
|
||||
'京喜账单',
|
||||
'京东网银钱包',
|
||||
'抖音',
|
||||
'得物',
|
||||
];
|
||||
|
||||
/** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */
|
||||
private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [
|
||||
'京东网银钱包',
|
||||
];
|
||||
|
||||
/**
|
||||
* 京东「直营补贴款」备注适用的京喜店铺(前缀)。
|
||||
*
|
||||
@ -289,21 +285,14 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private static function receiptReasonNotBasisMarkedSql(): string
|
||||
{
|
||||
return "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
|
||||
}
|
||||
|
||||
/**
|
||||
* 「差额异常备注」基础原因(不落库),按页面优先级排列。
|
||||
* 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。
|
||||
* 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0;
|
||||
* 基准金额没有减掉优惠/冲减时,差额正好等于对应项。差额按有效值判定(未匹配按基准金额=0)。
|
||||
*
|
||||
* 这里保留可重叠的原始条件;需要“一行只取第一个原因”时走
|
||||
* receiptDifferenceRemarkIndexSql(),由单次 CASE 按顺序命中,避免把
|
||||
* 前序大段条件重复拼进后续每一条,拖慢筛选联动。
|
||||
*
|
||||
* @return array<string,string> 备注文字 => 原始 SQL 条件
|
||||
* @return array<string,string> 备注文字 => SQL 条件(已按顺序排除前面命中的行)
|
||||
*/
|
||||
private static function receiptDifferenceRemarkBaseRules(): array
|
||||
private static function receiptDifferenceRemarkConditions(): array
|
||||
{
|
||||
$basis = self::differenceBasis();
|
||||
$column = $basis['column'];
|
||||
@ -372,13 +361,9 @@ class Pages extends Controller
|
||||
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
|
||||
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
|
||||
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
|
||||
// 拼多多全额退款:先于通用「未减优惠/冲减」判定,避免抵减项把退款单误标成其他原因。
|
||||
$pddFullRefundHit = "platform = '拼多多'"
|
||||
. ' AND ABS(COALESCE(income_amount, 0) + COALESCE(refund_amount, 0)) <= 0.02';
|
||||
$rules['订单实付含已退款部分'] = $pddFullRefundHit;
|
||||
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
|
||||
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
|
||||
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
|
||||
$notBasisMarked = "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
|
||||
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005 AND " . $notBasisMarked;
|
||||
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005 AND " . $notBasisMarked;
|
||||
$rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005 AND " . $notBasisMarked;
|
||||
@ -414,66 +399,29 @@ class Pages extends Controller
|
||||
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
|
||||
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
|
||||
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
|
||||
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit;
|
||||
|
||||
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
|
||||
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
|
||||
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
|
||||
$rules['差额=平台支付立减'] = "platform = '拼多多'"
|
||||
. " AND match_status = '已匹配'"
|
||||
. ' AND ' . $effective . ' > 0.005'
|
||||
. ' AND COALESCE(refund_amount, 0) < -0.005'
|
||||
. ' AND ' . $pddPaymentDiscount . ' > 0.005'
|
||||
. ' AND ' . $pddPaymentDiscount . ' <= 20.005'
|
||||
. ' AND ' . $notBasisMarked;
|
||||
|
||||
// 1688 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
|
||||
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
|
||||
$rules['订单实付含平台红包补贴'] = "platform = '淘系支付宝'"
|
||||
. " AND (store_name LIKE '新佰广%' OR store_name LIKE '阿里巴巴%' OR store_name LIKE '1688%')"
|
||||
. ' AND match_status = \'已匹配\''
|
||||
. ' AND ' . $effective . ' < -0.005'
|
||||
. ' AND ABS(COALESCE(discount_amount, 0)) < 0.005'
|
||||
. ' AND ABS(COALESCE(offset_amount, 0)) < 0.005'
|
||||
. ' AND ABS(COALESCE(refund_amount, 0)) < 0.005'
|
||||
. ' AND COALESCE(paid_amount, 0) > COALESCE(income_amount, 0) + 0.005'
|
||||
. ' AND ' . $notBasisMarked;
|
||||
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
|
||||
|
||||
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
|
||||
$rules['差额=分位四舍五入尾差'] = "platform = '京喜账单' AND ABS(" . $effective . ') <= 0.0301 AND ' . $notBasisMarked;
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @return list<string> 差额异常备注的下拉顺序(含兜底档) */
|
||||
private static function receiptDifferenceRemarkLabels(): array
|
||||
{
|
||||
return array_merge(array_keys(self::receiptDifferenceRemarkBaseRules()), ['差额待核实']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 差额异常备注的优先级序号(1 开始;0=无备注)。
|
||||
*
|
||||
* 单次 CASE 命中后即停止,筛选统计与备注筛选共用同一口径。相比把
|
||||
* 18 条条件逐条带上前序 NOT 再分别 SUM,避免重复计算退款等关联子查询。
|
||||
*/
|
||||
private static function receiptDifferenceRemarkIndexSql(): string
|
||||
{
|
||||
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
|
||||
$sql = 'CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 0';
|
||||
$index = 1;
|
||||
foreach (self::receiptDifferenceRemarkBaseRules() as $condition) {
|
||||
$sql .= ' WHEN (' . $condition . ') THEN ' . $index;
|
||||
$index++;
|
||||
$conditions = [];
|
||||
$seen = [];
|
||||
foreach ($rules as $label => $condition) {
|
||||
$condition = 'ABS(' . $effective . ') > 0.005 AND (' . $condition . ')';
|
||||
$conditions[$label] = $seen === [] ? $condition : $condition . ' AND NOT (' . implode(' OR ', $seen) . ')';
|
||||
$seen[] = '(' . $condition . ')';
|
||||
}
|
||||
|
||||
return $sql . ' WHEN ' . self::receiptReasonNotBasisMarkedSql() . ' THEN ' . $index . ' ELSE 0 END';
|
||||
// 兜底档:有差额但没命中任何原因 →「差额待核实」(与页面渲染的兜底备注保持一致)。
|
||||
$conditions['差额待核实'] = 'ABS(' . $effective . ') > 0.005 AND NOT (' . implode(' OR ', $seen) . ') AND ' . $notBasisMarked;
|
||||
|
||||
return $conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该单的聚水潭应付(主单优先 pay_amount)是否大于账单收入(= 含国补/补贴类差额的口径)。
|
||||
*
|
||||
* 与 receiptDifferenceRemarkBaseRules() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
|
||||
* 与 receiptDifferenceRemarkConditions() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
|
||||
* 页面渲染与导出逐行调用时不会对同一单重复查询。
|
||||
*/
|
||||
private static function receiptJdSubsidyPayExceedsIncome(string $orderNo, float $income): bool
|
||||
@ -544,13 +492,6 @@ class Pages extends Controller
|
||||
return abs((float) ($row->paid_amount ?? 0) - (float) ($row->product_amount ?? 0)) < 0.02;
|
||||
}
|
||||
|
||||
private static function is1688StoreName(string $storeName): bool
|
||||
{
|
||||
return str_starts_with($storeName, '新佰广')
|
||||
|| str_starts_with($storeName, '阿里巴巴')
|
||||
|| str_starts_with($storeName, '1688');
|
||||
}
|
||||
|
||||
/** 单行的「差额异常备注」(与筛选条件同一套口径,页面与导出共用)。 */
|
||||
/**
|
||||
* 页面口径的「有效差额」SQL:口径兜底行(备注为空、但能对上实收/应付)按 0 计。
|
||||
@ -558,10 +499,17 @@ class Pages extends Controller
|
||||
*/
|
||||
private static function receiptEffectiveDifferenceSql(): string
|
||||
{
|
||||
// 备注序号 > 0 表示命中了明确原因;只有口径兜底且没有明确原因时才归零。
|
||||
$indexSql = self::receiptDifferenceRemarkIndexSql();
|
||||
// 注意:要把「差额待核实」这条兜底规则排除掉,否则它本身就是"都没命中",
|
||||
// 会让这里永远不成立(口径兜底行就归不了零)。
|
||||
$rules = self::receiptDifferenceRemarkConditions();
|
||||
unset($rules['差额待核实']);
|
||||
$ruleUnion = '(' . implode(' OR ', array_map(
|
||||
static fn (string $condition): string => '(' . $condition . ')',
|
||||
$rules
|
||||
)) . ')';
|
||||
|
||||
return "CASE WHEN remark IN (" . self::receiptBasisFallbackSqlList() . ')'
|
||||
. " AND COALESCE({$indexSql}, 0) = 0 THEN 0"
|
||||
. " AND NOT COALESCE({$ruleUnion}, 0) THEN 0"
|
||||
. ' ELSE ' . self::receiptDefaultDifferenceSql() . ' END';
|
||||
}
|
||||
|
||||
@ -577,7 +525,14 @@ class Pages extends Controller
|
||||
*/
|
||||
private static function receiptFallbackDifferenceAdjustmentSql(): string
|
||||
{
|
||||
return 'CASE WHEN COALESCE(' . self::receiptDifferenceRemarkIndexSql() . ', 0) = 0 THEN -('
|
||||
$rules = self::receiptDifferenceRemarkConditions();
|
||||
unset($rules['差额待核实']);
|
||||
$ruleUnion = '(' . implode(' OR ', array_map(
|
||||
static fn (string $condition): string => '(' . $condition . ')',
|
||||
$rules
|
||||
)) . ')';
|
||||
|
||||
return 'CASE WHEN NOT COALESCE(' . $ruleUnion . ', 0) THEN -('
|
||||
. self::receiptDefaultDifferenceSql() . ') ELSE 0 END';
|
||||
}
|
||||
|
||||
@ -616,11 +571,6 @@ class Pages extends Controller
|
||||
if (abs($product) < 0.005) {
|
||||
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
|
||||
}
|
||||
// 拼多多全额退款优先于其它抵减等式:退款后实际净收款为 0,不应再落成优惠/冲减异常。
|
||||
if (($row->platform ?? '') === '拼多多'
|
||||
&& abs($income + (float) ($row->refund_amount ?? 0)) <= 0.02) {
|
||||
return '订单实付含已退款部分';
|
||||
}
|
||||
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
|
||||
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
|
||||
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
|
||||
@ -727,17 +677,6 @@ class Pages extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
|
||||
$pddPaymentDiscount = $difference - $discount - $offset;
|
||||
if (($row->platform ?? '') === '拼多多'
|
||||
&& ! $basisMarked
|
||||
&& $difference > 0.005
|
||||
&& (float) ($row->refund_amount ?? 0) < -0.005
|
||||
&& $pddPaymentDiscount > 0.005
|
||||
&& $pddPaymentDiscount <= 20.005) {
|
||||
return '差额=平台支付立减';
|
||||
}
|
||||
|
||||
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
|
||||
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
|
||||
$rawRemark = trim((string) ($row->remark ?? ''));
|
||||
@ -745,18 +684,6 @@ class Pages extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1688 先采后付红包补贴:订单实付包含平台补贴,支付宝到账只含现金部分。
|
||||
// 先只作为已知原因标注,不改变差额金额或税务收入。
|
||||
if (($row->platform ?? '') === '淘系支付宝'
|
||||
&& $difference < -0.005
|
||||
&& self::is1688StoreName((string) ($row->store_name ?? ''))
|
||||
&& abs($discount) < 0.005
|
||||
&& abs($offset) < 0.005
|
||||
&& abs((float) ($row->refund_amount ?? 0)) < 0.005
|
||||
&& (float) ($row->paid_amount ?? 0) > $income + 0.005) {
|
||||
return '订单实付含平台红包补贴';
|
||||
}
|
||||
|
||||
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
|
||||
if (($row->platform ?? '') === '京喜账单' && abs($difference) <= 0.0301) {
|
||||
return '差额=分位四舍五入尾差';
|
||||
@ -1324,8 +1251,7 @@ class Pages extends Controller
|
||||
// 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。
|
||||
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
->where('report_month', $month);
|
||||
|
||||
// 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。
|
||||
$aggQuery = $base();
|
||||
@ -1390,8 +1316,7 @@ class Pages extends Controller
|
||||
now()->addHours(6),
|
||||
static function () use ($connection, $month): array {
|
||||
$maker = static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
->where('report_month', $month);
|
||||
|
||||
return [
|
||||
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
|
||||
@ -1402,20 +1327,17 @@ class Pages extends Controller
|
||||
);
|
||||
$stats['selected_platforms'] = $platformFilter;
|
||||
$stats['selected_stores'] = $storeFilter;
|
||||
$stats['platform_options'] = array_values(array_diff(
|
||||
array_unique(array_merge(
|
||||
self::RECEIPT_PLATFORM_OPTIONS,
|
||||
array_map('strval', $filterOptions['platforms'] ?? []),
|
||||
)),
|
||||
self::RECEIPT_MATCH_EXCLUDED_PLATFORMS,
|
||||
));
|
||||
$stats['platform_options'] = array_values(array_unique(array_merge(
|
||||
self::RECEIPT_PLATFORM_OPTIONS,
|
||||
array_map('strval', $filterOptions['platforms'] ?? []),
|
||||
)));
|
||||
$stats['store_options'] = array_values(array_unique(array_merge(
|
||||
$storeFilter,
|
||||
array_map('strval', $filterOptions['stores'] ?? []),
|
||||
)));
|
||||
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
|
||||
// 差额异常备注下拉:只保留「差额为什么产生」的原因备注(按差额公式判定)
|
||||
$stats['remark_options'] = self::receiptDifferenceRemarkLabels();
|
||||
$stats['remark_options'] = array_values(array_keys(self::receiptDifferenceRemarkConditions()));
|
||||
// 未匹配原因下拉:未匹配行在库里的原因标注
|
||||
$stats['unmatched_reason_options'] = self::unmatchedReasonOptions();
|
||||
|
||||
@ -1488,21 +1410,20 @@ class Pages extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = self::receiptDifferenceRemarkLabels();
|
||||
$indexes = [];
|
||||
foreach ($remarkFilter as $label) {
|
||||
$index = array_search($label, $labels, true);
|
||||
if ($index !== false) {
|
||||
$indexes[] = (int) $index + 1;
|
||||
}
|
||||
}
|
||||
if ($indexes === []) {
|
||||
$differenceConditions = self::receiptDifferenceRemarkConditions();
|
||||
$differenceLabels = array_values(array_intersect($remarkFilter, array_keys($differenceConditions)));
|
||||
if ($differenceLabels === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereRaw(
|
||||
self::receiptDifferenceRemarkIndexSql() . ' IN (' . implode(', ', array_unique($indexes)) . ')'
|
||||
);
|
||||
$query->where(function ($group) use ($differenceLabels, $differenceConditions): void {
|
||||
foreach ($differenceLabels as $index => $label) {
|
||||
$method = $index === 0 ? 'where' : 'orWhere';
|
||||
$group->{$method}(function ($sub) use ($label, $differenceConditions): void {
|
||||
$sub->whereRaw($differenceConditions[$label]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1561,8 +1482,7 @@ class Pages extends Controller
|
||||
{
|
||||
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
->where('report_month', $month);
|
||||
|
||||
foreach ($filters as $key => $values) {
|
||||
if ($key === 'difference_state' || $values === [] || $values === '') {
|
||||
@ -1628,7 +1548,6 @@ class Pages extends Controller
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->select('report_month')
|
||||
->distinct()
|
||||
->orderByDesc('report_month')
|
||||
@ -1692,196 +1611,22 @@ class Pages extends Controller
|
||||
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
|
||||
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
|
||||
|
||||
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
|
||||
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
|
||||
return [
|
||||
'month' => substr($month, 0, 7),
|
||||
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
|
||||
'facets' => [
|
||||
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
|
||||
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
|
||||
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
|
||||
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())),
|
||||
'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()),
|
||||
'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo),
|
||||
],
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次请求生成全部筛选联动计数。
|
||||
*
|
||||
* 多个筛选项如果共用同一组“其它条件”,只查一次再一起聚合。页面当前先按
|
||||
* 差额备注筛选时,原先 6 次重复全表条件判断会合并成 2 次查询。
|
||||
*
|
||||
* @param array<string,list<string>> $filters
|
||||
* @return array<string,array{values:list<string>,counts:array<string,int>}>
|
||||
*/
|
||||
private function receiptOrderMatchFacets(string $month, array $filters, string $orderNo): array
|
||||
{
|
||||
$facetOrder = [
|
||||
'platform' => self::RECEIPT_PLATFORM_OPTIONS,
|
||||
'store_name' => [],
|
||||
'match_status' => self::RECEIPT_STATUS_OPTIONS,
|
||||
'remark' => self::receiptDifferenceRemarkLabels(),
|
||||
'unmatched_reason' => self::unmatchedReasonOptions(),
|
||||
'difference_state' => ['有差额', '无差额'],
|
||||
];
|
||||
|
||||
// “统计某个筛选本身”时排除该筛选;条件完全相同的筛选项合并成一个查询。
|
||||
$groups = [];
|
||||
foreach (array_keys($facetOrder) as $column) {
|
||||
$signature = $filters;
|
||||
unset($signature[$column]);
|
||||
$signature = array_filter(
|
||||
$signature,
|
||||
static fn ($values): bool => $values !== [] && $values !== ''
|
||||
);
|
||||
ksort($signature);
|
||||
$key = md5(serialize([$orderNo, $signature]));
|
||||
$groups[$key]['filters'] = $signature;
|
||||
$groups[$key]['columns'][] = $column;
|
||||
}
|
||||
|
||||
$facets = [];
|
||||
foreach ($groups as $group) {
|
||||
if ($group['filters'] === []) {
|
||||
foreach ($group['columns'] as $column) {
|
||||
$facets[$column] = $column === 'difference_state'
|
||||
? $this->receiptDifferenceStateFacet($month, $filters, $orderNo)
|
||||
: $this->receiptOrderMatchFacet($month, $column, $filters, $orderNo, $facetOrder[$column]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts = $this->receiptOrderMatchFilteredFacetCounts(
|
||||
$month,
|
||||
$group['filters'],
|
||||
$orderNo,
|
||||
$group['columns']
|
||||
);
|
||||
foreach ($group['columns'] as $column) {
|
||||
$columnCounts = $counts[$column] ?? [];
|
||||
$ordered = array_values(array_intersect($facetOrder[$column], array_keys($columnCounts)));
|
||||
$rest = array_values(array_diff(array_keys($columnCounts), $ordered));
|
||||
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
$facets[$column] = [
|
||||
'values' => array_merge($ordered, $rest),
|
||||
'counts' => $columnCounts,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_replace(array_fill_keys(array_keys($facetOrder), ['values' => [], 'counts' => []]), $facets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 共用同一组筛选条件时,一次分组查询算出多个筛选项的计数。
|
||||
*
|
||||
* @param array<string,list<string>> $filters
|
||||
* @param list<string> $columns
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
private function receiptOrderMatchFilteredFacetCounts(string $month, array $filters, string $orderNo, array $columns): array
|
||||
{
|
||||
$labels = self::receiptDifferenceRemarkLabels();
|
||||
$basisMarks = ['订单实付按卖家实收核对', '订单实付按应付/已付核对'];
|
||||
$remarkIndexes = [];
|
||||
foreach ((array) ($filters['remark'] ?? []) as $label) {
|
||||
$index = array_search($label, $labels, true);
|
||||
if ($index !== false) {
|
||||
$remarkIndexes[] = (int) $index + 1;
|
||||
}
|
||||
}
|
||||
$differenceValues = array_map('strval', (array) ($filters['difference_state'] ?? []));
|
||||
$wantDifference = in_array('有差额', $differenceValues, true);
|
||||
$wantNoDifference = in_array('无差额', $differenceValues, true);
|
||||
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$inner = DB::connection($connection)
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->select(['platform', 'store_name', 'match_status', 'remark'])
|
||||
->selectRaw(self::receiptDefaultDifferenceSql() . ' AS base_difference')
|
||||
->selectRaw(self::receiptDifferenceRemarkIndexSql() . ' AS reason_index');
|
||||
|
||||
foreach ($filters as $key => $values) {
|
||||
if ($key === 'remark' || $key === 'difference_state' || $values === []) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'unmatched_reason') {
|
||||
$this->applyReceiptUnmatchedReasonFilter($inner, (array) $values);
|
||||
continue;
|
||||
}
|
||||
$inner->whereIn($key, $values);
|
||||
}
|
||||
if (trim($orderNo) !== '') {
|
||||
$inner->where('online_order_no', 'like', '%' . addcslashes(trim($orderNo), '%_\\') . '%');
|
||||
}
|
||||
|
||||
$outer = DB::connection($connection)
|
||||
->query()
|
||||
->fromSub($inner, 'facet_rows');
|
||||
if ($remarkIndexes !== []) {
|
||||
$outer->whereIn('reason_index', array_values(array_unique($remarkIndexes)));
|
||||
}
|
||||
if ($wantDifference !== $wantNoDifference) {
|
||||
$effective = 'CASE WHEN remark IN ('
|
||||
. implode(', ', array_map(static fn (string $mark): string => "'" . $mark . "'", $basisMarks))
|
||||
. ') AND reason_index = 0 THEN 0 ELSE base_difference END';
|
||||
$outer->whereRaw('ABS(' . $effective . ')' . ($wantDifference ? ' > 0.005' : ' <= 0.005'));
|
||||
}
|
||||
|
||||
$rows = $outer
|
||||
->select(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->groupBy(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
|
||||
->get();
|
||||
|
||||
$counts = [
|
||||
'platform' => [],
|
||||
'store_name' => [],
|
||||
'match_status' => [],
|
||||
'remark' => [],
|
||||
'unmatched_reason' => [],
|
||||
'difference_state' => [],
|
||||
];
|
||||
$wanted = array_fill_keys($columns, true);
|
||||
foreach ($rows as $row) {
|
||||
$total = (int) ($row->total ?? 0);
|
||||
$platform = (string) ($row->platform ?? '');
|
||||
$store = (string) ($row->store_name ?? '');
|
||||
$status = (string) ($row->match_status ?? '');
|
||||
$rawRemark = trim((string) ($row->remark ?? ''));
|
||||
$reasonIndex = (int) ($row->reason_index ?? 0);
|
||||
$reasonLabel = $reasonIndex > 0 ? ($labels[$reasonIndex - 1] ?? null) : null;
|
||||
|
||||
if (isset($wanted['platform']) && $platform !== '') {
|
||||
$counts['platform'][$platform] = ($counts['platform'][$platform] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['store_name']) && $store !== '') {
|
||||
$counts['store_name'][$store] = ($counts['store_name'][$store] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['match_status']) && $status !== '') {
|
||||
$counts['match_status'][$status] = ($counts['match_status'][$status] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['remark']) && $reasonLabel !== null) {
|
||||
$counts['remark'][$reasonLabel] = ($counts['remark'][$reasonLabel] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['unmatched_reason'])) {
|
||||
$unmatchedReason = ($status === '未匹配' && $rawRemark !== '')
|
||||
? $rawRemark
|
||||
: (in_array($rawRemark, $basisMarks, true) ? $rawRemark : null);
|
||||
if ($unmatchedReason !== null) {
|
||||
$counts['unmatched_reason'][$unmatchedReason] = ($counts['unmatched_reason'][$unmatchedReason] ?? 0) + $total;
|
||||
}
|
||||
}
|
||||
if (isset($wanted['difference_state'])) {
|
||||
$difference = (float) ($row->base_difference ?? 0);
|
||||
if ($reasonIndex === 0 && in_array($rawRemark, $basisMarks, true)) {
|
||||
$difference = 0.0;
|
||||
}
|
||||
$differenceLabel = abs($difference) > 0.005 ? '有差额' : '无差额';
|
||||
$counts['difference_state'][$differenceLabel] = ($counts['difference_state'][$differenceLabel] ?? 0) + $total;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
|
||||
*
|
||||
@ -1920,24 +1665,23 @@ class Pages extends Controller
|
||||
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
// 差额异常备注联动:5 条原因一次查询算完(原来是 5 次全表 count,下拉要等 1 秒多)
|
||||
$differenceCounts = [];
|
||||
$differenceLabels = array_keys(self::receiptDifferenceRemarkConditions());
|
||||
if ($column === 'remark') {
|
||||
// 一行只按优先级命中一次,再按序号分组,避免 18 条规则各跑一遍全表。
|
||||
$indexSql = self::receiptDifferenceRemarkIndexSql();
|
||||
$rows = (clone $query)
|
||||
->selectRaw($indexSql . ' AS reason_index')
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->groupByRaw('reason_index')
|
||||
->pluck('total', 'reason_index');
|
||||
$labels = self::receiptDifferenceRemarkLabels();
|
||||
foreach ($rows as $index => $total) {
|
||||
$label = $labels[(int) $index - 1] ?? null;
|
||||
if ($label !== null && (int) $total > 0) {
|
||||
$counts[$label] = (int) $total;
|
||||
}
|
||||
$expressions = [];
|
||||
foreach (array_values(self::receiptDifferenceRemarkConditions()) as $index => $condition) {
|
||||
$expressions[] = 'SUM(CASE WHEN (' . $condition . ') THEN 1 ELSE 0 END) as c' . $index;
|
||||
}
|
||||
} elseif ($column === 'unmatched_reason') {
|
||||
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
|
||||
$row = (clone $query)->selectRaw(implode(', ', $expressions))->first();
|
||||
foreach ($differenceLabels as $index => $label) {
|
||||
$differenceCounts[$label] = (int) ($row->{'c' . $index} ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
|
||||
if ($column === 'unmatched_reason') {
|
||||
$base = clone $query;
|
||||
$rows = $query->where('match_status', '未匹配')
|
||||
->select('remark')
|
||||
@ -1956,7 +1700,8 @@ class Pages extends Controller
|
||||
$counts[$basisMark] = (int) $marker;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} elseif ($column !== 'remark') {
|
||||
// 差额异常备注是页面级判定,不再按库里的标注列分组
|
||||
$rows = $query->select($column)
|
||||
->selectRaw('count(*) as total')
|
||||
->whereNotNull($column)
|
||||
@ -1967,6 +1712,11 @@ class Pages extends Controller
|
||||
$counts[(string) $value] = (int) $total;
|
||||
}
|
||||
}
|
||||
foreach ($differenceCounts as $label => $total) {
|
||||
if ((int) $total > 0) {
|
||||
$counts[$label] = (int) $total;
|
||||
}
|
||||
}
|
||||
|
||||
$ordered = array_values(array_intersect($order, array_keys($counts)));
|
||||
$rest = array_values(array_diff(array_keys($counts), $ordered));
|
||||
@ -2048,8 +1798,6 @@ class Pages extends Controller
|
||||
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
|
||||
'refund_amount' => ['refund_amount', '退款金额', 'money'],
|
||||
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
|
||||
'planting_reimbursement_amount' => ['planting_reimbursement_amount', '种菜报销', 'money'],
|
||||
'planting_unreimbursed_amount' => ['planting_unreimbursed_amount', '种菜未报销', 'money'],
|
||||
'product_amount' => ['product_amount', '商品金额', 'money'],
|
||||
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
|
||||
'difference' => ['difference', '差额', 'money'],
|
||||
@ -2060,7 +1808,6 @@ class Pages extends Controller
|
||||
[, $month] = $this->receiptOrderMatchMonths();
|
||||
|
||||
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
|
||||
|
||||
$platforms = $this->receiptOrderMatchFilterValues($request->query('platform'));
|
||||
@ -2209,6 +1956,8 @@ class Pages extends Controller
|
||||
'online_order_no' => trim((string) request('online_order_no', '')),
|
||||
'product_code' => trim((string) request('product_code', '')),
|
||||
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
|
||||
'missing_price' => request()->boolean('missing_price') ? '1' : '',
|
||||
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
|
||||
];
|
||||
|
||||
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
@ -2228,9 +1977,7 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
// 页面不再提供「只看未匹配单价 / 包含-不看仓」两个开关:默认全部数据展示,
|
||||
// 已匹配与未匹配单价、所有仓库(含「-不看」仓)都纳入统计与列表。
|
||||
$applyFilters = static function ($query) use ($filters): void {
|
||||
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
|
||||
$query->where('report_month', $filters['month'] . '-01');
|
||||
if ($filters['platform'] !== '') {
|
||||
$query->where('platform', $filters['platform']);
|
||||
@ -2243,22 +1990,41 @@ class Pages extends Controller
|
||||
if ($filters['warehouse_short_name'] !== '') {
|
||||
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
|
||||
}
|
||||
if ($filters['missing_price'] === '1') {
|
||||
$query->whereNull('unit_price');
|
||||
}
|
||||
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
|
||||
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
|
||||
if ($excludedWarehouseOnly) {
|
||||
$query->where('warehouse_short_name', 'like', '%-不看%');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($filters['include_excluded'] !== '1') {
|
||||
$query->where(static function ($inner): void {
|
||||
$inner->whereNull('warehouse_short_name')
|
||||
->orWhere('warehouse_short_name', 'not like', '%-不看%');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$countQuery = $base();
|
||||
$applyFilters($countQuery);
|
||||
$total = (int) $countQuery->count();
|
||||
|
||||
$excludedQuery = $base();
|
||||
$applyFilters($excludedQuery, true);
|
||||
$excludedRows = (int) $excludedQuery->count();
|
||||
|
||||
$aggQuery = $base();
|
||||
$applyFilters($aggQuery);
|
||||
$agg = $aggQuery->selectRaw('
|
||||
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
|
||||
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
|
||||
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN 1 ELSE 0 END) as missing_price_rows,
|
||||
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN product_quantity ELSE 0 END) as missing_price_quantity,
|
||||
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
|
||||
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
|
||||
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows,
|
||||
SUM(CASE WHEN price_source LIKE \'线上代发价格%\' THEN 1 ELSE 0 END) as fallback_price_rows,
|
||||
SUM(CASE WHEN price_source = \'普通商品资料成本价\' THEN 1 ELSE 0 END) as catalog_price_rows,
|
||||
SUM(CASE WHEN remark LIKE \'%口径不一致%\' THEN 1 ELSE 0 END) as unit_mismatch_rows
|
||||
')->first();
|
||||
|
||||
@ -2302,8 +2068,8 @@ class Pages extends Controller
|
||||
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
|
||||
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
|
||||
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
|
||||
'excluded_rows' => $excludedRows,
|
||||
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
|
||||
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
|
||||
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
|
||||
],
|
||||
'productCostMonths' => $months,
|
||||
@ -2441,7 +2207,6 @@ class Pages extends Controller
|
||||
'financial-analysis-months-v1',
|
||||
now()->addMinutes(10),
|
||||
static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->distinct()->orderByDesc('report_month')->pluck('report_month')
|
||||
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
|
||||
);
|
||||
@ -2468,7 +2233,6 @@ class Pages extends Controller
|
||||
$rows = [];
|
||||
$incomeRows = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $reportMonth)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
|
||||
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
|
||||
->groupBy('platform', 'store_name')
|
||||
@ -2583,7 +2347,15 @@ class Pages extends Controller
|
||||
if ($warehouse !== '') {
|
||||
$query->where('warehouse_short_name', $warehouse);
|
||||
}
|
||||
// 导出与页面一致:全部数据(含未匹配单价与「-不看」仓)。
|
||||
if ($request->boolean('missing_price')) {
|
||||
$query->whereNull('unit_price');
|
||||
}
|
||||
if (! $request->boolean('include_excluded')) {
|
||||
$query->where(static function ($inner): void {
|
||||
$inner->whereNull('warehouse_short_name')
|
||||
->orWhere('warehouse_short_name', 'not like', '%-不看%');
|
||||
});
|
||||
}
|
||||
|
||||
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
|
||||
|
||||
@ -2969,8 +2741,7 @@ class Pages extends Controller
|
||||
}));
|
||||
}
|
||||
|
||||
// 货款/退货明细较宽,固定每页 15 行,减少纵向滚动并保持横向核对区稳定。
|
||||
$perPage = in_array($dataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50;
|
||||
$perPage = 50;
|
||||
$currentPage = max((int) request('page', 1), 1);
|
||||
|
||||
return [
|
||||
|
||||
@ -32,15 +32,6 @@ class PlantingWorkOrders extends Controller
|
||||
default => '-扣款',
|
||||
};
|
||||
|
||||
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
|
||||
if (request()->query('format') === 'reimbursement') {
|
||||
return $this->csv(
|
||||
'拍A发B发运营-' . $filters['month'],
|
||||
PlantingReimbursementPageData::COLUMNS,
|
||||
static fn () => $data->rows($filters),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->csv(
|
||||
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
|
||||
$data->exportColumns(),
|
||||
|
||||
@ -6,7 +6,6 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
@ -30,7 +29,6 @@ class SupplierReconciliation extends Controller
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
@ -107,23 +105,6 @@ class SupplierReconciliation extends Controller
|
||||
return $this->returnCsvExporter->download($rows, $supplier);
|
||||
}
|
||||
|
||||
public function exportComparison(Request $request): StreamedResponse
|
||||
{
|
||||
$request->validate([
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->comparisonCsvExporter->download(
|
||||
$detail,
|
||||
$supplier,
|
||||
trim((string) $request->query('month', '')),
|
||||
trim((string) $request->query('reconciler', '')),
|
||||
);
|
||||
}
|
||||
|
||||
public function saveDingTalkRobot(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
@ -150,10 +131,9 @@ class SupplierReconciliation extends Controller
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
'scope' => ['required', 'in:all,row,selected'],
|
||||
'purchase_no' => ['nullable', 'string', 'max:191'],
|
||||
'date' => ['nullable', 'string', 'max:32'],
|
||||
'barcode' => ['nullable', 'string', 'max:191'],
|
||||
'difference_type' => ['nullable', 'string', 'max:32'],
|
||||
'unit_price' => ['nullable', 'numeric'],
|
||||
'selected_rows' => ['nullable', 'array', 'max:600'],
|
||||
'selected_rows.*' => ['string', 'max:2048'],
|
||||
]);
|
||||
@ -165,16 +145,14 @@ class SupplierReconciliation extends Controller
|
||||
));
|
||||
|
||||
if ($request->input('scope') === 'row') {
|
||||
$purchaseNo = trim((string) $request->input('purchase_no'));
|
||||
$date = trim((string) $request->input('date'));
|
||||
$barcode = trim((string) $request->input('barcode'));
|
||||
$type = trim((string) $request->input('difference_type'));
|
||||
$unitPrice = (float) $request->input('unit_price');
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
|
||||
&& (string) ($row['date'] ?? '') === $date
|
||||
&& (string) ($row['barcode'] ?? '') === $barcode
|
||||
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
|
||||
&& (string) ($row['type'] ?? '') === $type
|
||||
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
|
||||
));
|
||||
} elseif ($request->input('scope') === 'selected') {
|
||||
$selected = [];
|
||||
@ -236,10 +214,9 @@ class SupplierReconciliation extends Controller
|
||||
private function differenceRowKey(array $row): string
|
||||
{
|
||||
return implode("\0", [
|
||||
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
|
||||
trim((string) ($row['date'] ?? '')),
|
||||
trim((string) ($row['barcode'] ?? '')),
|
||||
trim((string) ($row['type'] ?? '')),
|
||||
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ class CourierQuoteVersion extends Model
|
||||
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
|
||||
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
|
||||
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
|
||||
'billing_structure', 'billing_rows',
|
||||
];
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
@ -27,8 +26,6 @@ class CourierQuoteVersion extends Model
|
||||
'effective_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'voided_at' => 'datetime',
|
||||
'billing_structure' => 'array',
|
||||
'billing_rows' => 'array',
|
||||
];
|
||||
|
||||
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
||||
@ -59,14 +56,6 @@ class CourierQuoteVersion extends Model
|
||||
: $this->effective_date->toDateString() . ' 起';
|
||||
}
|
||||
|
||||
/** 是否已有可用于运费核算的结构化计费规则。 */
|
||||
public function hasBillingRules(): bool
|
||||
{
|
||||
$mode = $this->billing_structure['mode'] ?? null;
|
||||
|
||||
return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== [];
|
||||
}
|
||||
|
||||
public function isVoided(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VOIDED;
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
|
||||
*
|
||||
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
|
||||
*
|
||||
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
|
||||
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
|
||||
*/
|
||||
class CourierQuoteFreightCalculator
|
||||
{
|
||||
/** @var array<string,Collection<int,CourierQuoteVersion>> */
|
||||
private array $versions = [];
|
||||
|
||||
/**
|
||||
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
|
||||
* @return array{
|
||||
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
|
||||
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
|
||||
* }
|
||||
*/
|
||||
public function calculate(
|
||||
string $courierExpcode,
|
||||
DateTimeInterface $shipDate,
|
||||
string $province,
|
||||
float $grossWeight,
|
||||
?float $length = null,
|
||||
?float $width = null,
|
||||
?float $height = null,
|
||||
?string $city = null,
|
||||
?string $quoteType = null,
|
||||
): array {
|
||||
$result = [
|
||||
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
|
||||
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
|
||||
'amount' => null, 'formula' => null, 'reason' => null,
|
||||
];
|
||||
if (! is_finite($grossWeight) || $grossWeight < 0
|
||||
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
|
||||
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
|
||||
}
|
||||
|
||||
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
|
||||
if ($version === null) {
|
||||
return ['reason' => '发货日期没有生效的报价'] + $result;
|
||||
}
|
||||
$result['version_code'] = $version->version_code;
|
||||
$result['company_name'] = $version->company_name;
|
||||
if (! $version->hasBillingRules()) {
|
||||
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
|
||||
}
|
||||
|
||||
$structure = (array) $version->billing_structure;
|
||||
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
|
||||
if ($row === null) {
|
||||
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
|
||||
}
|
||||
$result['region'] = (string) $row['region'];
|
||||
|
||||
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
|
||||
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
|
||||
$settlement = max($grossWeight, (float) $volume);
|
||||
$result['volume_weight'] = $volume;
|
||||
if ($settlement <= 0) {
|
||||
return ['reason' => '缺少重量,无法计算运费'] + $result;
|
||||
}
|
||||
|
||||
$limit = (float) ($structure['weight_limit'] ?? 0);
|
||||
if ($limit > 0 && $settlement > $limit + 1e-9) {
|
||||
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
|
||||
}
|
||||
|
||||
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
|
||||
$minimum = (float) ($structure['min_weight'] ?? 0);
|
||||
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
|
||||
$result['billable_weight'] = $billable;
|
||||
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
|
||||
if ($freight['amount'] === null) {
|
||||
return ['reason' => $freight['reason']] + $result;
|
||||
}
|
||||
|
||||
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
|
||||
$result['base_amount'] = round($freight['amount'], 2);
|
||||
$result['surcharge'] = $surcharge;
|
||||
$result['amount'] = round($freight['amount'] + $surcharge, 2);
|
||||
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
|
||||
public static function regionKey(string $name): string
|
||||
{
|
||||
$text = (string) preg_replace('/\s+/u', '', $name);
|
||||
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
|
||||
|
||||
return match ($text) {
|
||||
'黑龙' => '黑龙江',
|
||||
'内蒙' => '内蒙古',
|
||||
default => $text,
|
||||
};
|
||||
}
|
||||
|
||||
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
|
||||
{
|
||||
if ($courierExpcode === '') {
|
||||
return null;
|
||||
}
|
||||
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
|
||||
$cacheKey = company_id() . ':' . $courierExpcode;
|
||||
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
|
||||
->with(['signature', 'change'])
|
||||
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
|
||||
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
|
||||
->where('company_name', $courierExpcode)))
|
||||
->get();
|
||||
|
||||
return CourierRequoteService::resolveAt(
|
||||
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
|
||||
$date,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function matchRow(array $rows, string $province, ?string $city): ?array
|
||||
{
|
||||
$cityKey = self::regionKey((string) $city);
|
||||
if ($cityKey !== '') {
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
|
||||
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
$key = self::regionKey($province);
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -40,13 +40,6 @@ final class CourierQuoteStructure
|
||||
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
|
||||
];
|
||||
|
||||
/**
|
||||
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
|
||||
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
|
||||
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
|
||||
*/
|
||||
public const SEGMENTS = 'segments';
|
||||
|
||||
public const MAX_BANDS = 12;
|
||||
|
||||
public const MAX_TIERS = 6;
|
||||
@ -279,120 +272,10 @@ final class CourierQuoteStructure
|
||||
? "首重{$fw}kg价+(重量-{$fw})*续重"
|
||||
: '面单+重量*费率') . $tierNote,
|
||||
'other' => '特殊计费,见附件报价表',
|
||||
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
|
||||
default => '面单+重量*费率' . $tierNote,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
|
||||
*
|
||||
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
|
||||
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
|
||||
*
|
||||
* @param array<string,mixed> $structure
|
||||
* @param array<string,mixed> $row
|
||||
* @return array{amount:?float,formula:?string,reason:?string}
|
||||
*/
|
||||
public static function freight(array $structure, array $row, float $weight): array
|
||||
{
|
||||
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
|
||||
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
|
||||
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
|
||||
if (! is_finite($weight) || $weight <= 0) {
|
||||
return $none('缺少有效重量,无法计算运费');
|
||||
}
|
||||
$w = self::kg($weight);
|
||||
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
|
||||
|
||||
if ($mode === self::SEGMENTS) {
|
||||
foreach ((array) ($row['segments'] ?? []) as $segment) {
|
||||
$lo = (float) ($segment['lo'] ?? 0);
|
||||
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
|
||||
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($segment['type'] ?? '');
|
||||
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
|
||||
return $none('不支持的分段计费方式,需人工核算');
|
||||
}
|
||||
if ($type === 'flat') {
|
||||
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
|
||||
}
|
||||
if (! isset($segment['base'], $segment['rate'])) {
|
||||
return $none('该重量段不发');
|
||||
}
|
||||
$base = (float) $segment['base'];
|
||||
$rate = (float) $segment['rate'];
|
||||
if ($type === 'first_add') {
|
||||
$fw = (float) ($segment['fw'] ?? 0);
|
||||
|
||||
return $within($weight, $fw)
|
||||
? $ok($base, '首重' . self::kg($base))
|
||||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $none('超出报价重量范围');
|
||||
}
|
||||
|
||||
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
|
||||
return $none('特殊计费,需人工核算');
|
||||
}
|
||||
|
||||
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
|
||||
$flat = array_values((array) ($row['flat'] ?? []));
|
||||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
|
||||
foreach ($bands as $index => $edge) {
|
||||
if ($within($weight, $edge)) {
|
||||
$price = $flat[$index] ?? null;
|
||||
|
||||
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
|
||||
}
|
||||
}
|
||||
|
||||
return $none('超出报价重量范围');
|
||||
}
|
||||
|
||||
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
|
||||
$index = count($tiers);
|
||||
foreach ($tiers as $i => $edge) {
|
||||
if ($within($weight, $edge)) {
|
||||
$index = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$rate = $row['rates'][$index] ?? null;
|
||||
$base = $row['base'] ?? null;
|
||||
if (! is_numeric($rate) || ! is_numeric($base)) {
|
||||
return $none('该重量段不发');
|
||||
}
|
||||
$rate = (float) $rate;
|
||||
$base = (float) $base;
|
||||
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
|
||||
if ($then === 'first_additional') {
|
||||
$fw = (float) ($structure['first_weight'] ?? 0);
|
||||
|
||||
return $within($weight, $fw)
|
||||
? $ok($base, '首重' . self::kg($base))
|
||||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||||
}
|
||||
|
||||
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
|
||||
public static function roundWeight(float $weight, string $rounding): float
|
||||
{
|
||||
return match ($rounding) {
|
||||
'ceil' => (float) ceil(round($weight, 6)),
|
||||
'round' => (float) floor($weight + 0.5),
|
||||
default => $weight,
|
||||
};
|
||||
}
|
||||
|
||||
public static function usesRates(string $mode): bool
|
||||
{
|
||||
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
|
||||
|
||||
@ -142,23 +142,6 @@ final class QuoteManagementSnapshot
|
||||
return $companyName !== '' ? $companyName : $name;
|
||||
}
|
||||
|
||||
/** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */
|
||||
private function billingSummary(CourierQuoteVersion $version): string
|
||||
{
|
||||
$structure = (array) $version->billing_structure;
|
||||
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
|
||||
$parts = array_filter([
|
||||
CourierQuoteStructure::describe($structure),
|
||||
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
|
||||
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
|
||||
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
|
||||
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
|
||||
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
|
||||
]);
|
||||
|
||||
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
|
||||
}
|
||||
|
||||
private function normalizeVersion(CourierQuoteVersion $version): array
|
||||
{
|
||||
$values = $this->decodeMatrix($version->values_json);
|
||||
@ -172,7 +155,6 @@ final class QuoteManagementSnapshot
|
||||
'company_name' => $version->company_name,
|
||||
'courier_expcode' => $version->courier_expcode,
|
||||
'billing_rules' => $version->billing_rules,
|
||||
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
|
||||
'carrier' => $version->carrier_name,
|
||||
'quote_type' => $version->quote_type,
|
||||
'effective_date' => $version->effective_date?->toDateString(),
|
||||
|
||||
@ -834,8 +834,6 @@ $navigation = [
|
||||
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
|
||||
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
|
||||
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
|
||||
['name' => 'planting_reimbursement_amount', 'label' => '种菜报销', 'kind' => 'number'],
|
||||
['name' => 'planting_unreimbursed_amount', 'label' => '种菜未报销', 'kind' => 'number'],
|
||||
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
|
||||
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
|
||||
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 报价版本增加结构化计费规则,供运费核算使用;原始单元格(values_json)保留不动。
|
||||
*
|
||||
* billing_structure:{mode, flat_bands, then_mode, first_weight, rate_tiers, rounding, volumetric_divisor,
|
||||
* weight_limit, min_weight(起算重量), per_ticket_surcharge, notes}
|
||||
* billing_rows: [{region, flat, base, rates}] 或分段计费 [{region, segments}]
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('business_courier_quote_versions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_structure')) {
|
||||
$table->longText('billing_structure')->nullable();
|
||||
}
|
||||
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_rows')) {
|
||||
$table->longText('billing_rows')->nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('business_courier_quote_versions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
|
||||
foreach (['billing_structure', 'billing_rows'] as $column) {
|
||||
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细新增「费用项」列:
|
||||
* 按科目映射表的 fee_memo(费用备注)归集到订单,一单多费用时用顿号连接。
|
||||
* 只用于展示,不参与收入、优惠、冲减、退款和差额计算。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->string('fee_item', 191)->nullable()->after('planting_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('fee_item');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 京东网银钱包账户不再进入业绩明细:清理历史镜像行,收、支均不展示。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::connection($this->connection())->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::connection($this->connection())
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('platform', '京东网银钱包')
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 历史镜像可由同步命令重建;本迁移只做排除清理,不回填已删除的数据。
|
||||
}
|
||||
};
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 撤销「费用项」功能:删除业绩明细镜像中的 fee_item 列。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('fee_item');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->string('fee_item', 191)->nullable()->after('planting_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细新增「种菜报销」列(放在种菜金额后面):
|
||||
* 仅按「种菜明细表」的订单号命中,金额取该订单的收入金额。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->decimal('planting_reimbursement_amount', 20, 4)
|
||||
->nullable()
|
||||
->after('planting_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('planting_reimbursement_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细新增「种菜未报销」列(放在种菜报销后面):
|
||||
* 按聚水潭订单标签明细「是否种菜订单=是」且未出现在种菜明细表的订单号命中,金额取收入金额。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->decimal('planting_unreimbursed_amount', 20, 4)
|
||||
->nullable()
|
||||
->after('planting_reimbursement_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('planting_unreimbursed_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
// 生产可能保留旧 SKU 级快照;使用独立源行基线,不修改或删除旧表。
|
||||
if (! $schema->hasTable('jushuitan_cost_price_observation_baselines')) {
|
||||
$schema->create('jushuitan_cost_price_observation_baselines', function (Blueprint $table): void {
|
||||
$table->char('source_key', 64)->primary();
|
||||
$table->string('sku_id', 255);
|
||||
$table->decimal('cost_price', 20, 4);
|
||||
$table->dateTime('captured_at', 6);
|
||||
});
|
||||
}
|
||||
if (! $schema->hasTable('jushuitan_cost_price_observations')) {
|
||||
$schema->create('jushuitan_cost_price_observations', function (Blueprint $table): void {
|
||||
$table->char('id', 64)->primary();
|
||||
$table->char('source_key', 64);
|
||||
$table->string('sku_id', 255);
|
||||
$table->decimal('before_price', 20, 4);
|
||||
$table->decimal('after_price', 20, 4);
|
||||
$table->dateTime('previous_observed_at', 6);
|
||||
$table->dateTime('observed_at', 6);
|
||||
$table->dateTime('source_modified_at', 6)->nullable();
|
||||
$table->index(['source_key', 'observed_at', 'id'], 'jst_cost_observations_lookup');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema->dropIfExists('jushuitan_cost_price_observations');
|
||||
$schema->dropIfExists('jushuitan_cost_price_observation_baselines');
|
||||
}
|
||||
};
|
||||
@ -7,11 +7,6 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
|
||||
DEPLOY_ACTOR="${8:-unknown}"
|
||||
RELEASE_APP_URL="${9:-}"
|
||||
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$RELEASE_DIR/release-guard.sh"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
|
||||
@ -70,12 +65,6 @@ if ! flock -n 9; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_RESULT=blocked
|
||||
trap 'release_status=$?; caiwu_record_release_result "$release_status"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
|
||||
exec 8>/run/caiwu-upstream-sync.lock
|
||||
echo "Waiting for active upstream synchronization before replacing the application container"
|
||||
@ -85,8 +74,7 @@ if ! flock -w 1800 8; then
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
caiwu_verify_release_base
|
||||
RELEASE_RESULT=running
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
|
||||
restore_previous_image() {
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
@ -108,8 +96,6 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
caiwu_install_release_files
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
|
||||
@ -210,8 +196,6 @@ CRON_TZ=Asia/Shanghai
|
||||
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
|
||||
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
|
||||
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
||||
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
|
||||
|
||||
@ -7,11 +7,6 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
|
||||
DEPLOY_ACTOR="${8:-unknown}"
|
||||
RELEASE_APP_URL="${9:-}"
|
||||
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$RELEASE_DIR/release-guard.sh"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
|
||||
@ -19,9 +14,7 @@ cleanup() {
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "activate-release.sh must run as root" >&2
|
||||
@ -76,10 +69,6 @@ if ! flock -n 9; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_RESULT=blocked
|
||||
trap 'release_status=$?; caiwu_record_release_result "$release_status"; cleanup' EXIT
|
||||
|
||||
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
|
||||
exec 8>/run/caiwu-upstream-sync.lock
|
||||
echo "Waiting for active upstream synchronization before replacing the application container"
|
||||
@ -89,8 +78,7 @@ if ! flock -w 1800 8; then
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
caiwu_verify_release_base
|
||||
RELEASE_RESULT=running
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
|
||||
restore_previous_image() {
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
@ -112,8 +100,6 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
caiwu_install_release_files
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
|
||||
@ -214,8 +200,6 @@ CRON_TZ=Asia/Shanghai
|
||||
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
|
||||
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
|
||||
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
||||
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Caller holds the production deployment lock. Does not restart any service.
|
||||
set -eu
|
||||
REMOTE_DIR="${1:-/opt/caiwuxitong}"
|
||||
SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
LEGACY_STUB="$SOURCE_DIR/retired-activation.sh"
|
||||
[ "$(id -u)" -eq 0 ] || { echo 'Release protection must be installed as root' >&2; exit 1; }
|
||||
[ -f "$LEGACY_STUB" ] || { echo 'Missing retired activation stub' >&2; exit 1; }
|
||||
|
||||
is_immutable() { lsattr -d "$1" 2>/dev/null | awk '{print $1}' | grep -q i; }
|
||||
needs_install=0
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
|
||||
continue
|
||||
fi
|
||||
if is_immutable "$REMOTE_DIR/$legacy"; then
|
||||
echo "Unexpected immutable legacy entry point: $legacy; verify it before changing protection" >&2
|
||||
exit 1
|
||||
fi
|
||||
needs_install=1
|
||||
done
|
||||
[ "$needs_install" -eq 1 ] || exit 0
|
||||
|
||||
# Test support on an owned temporary file before touching either entry point.
|
||||
probe="$(mktemp "$REMOTE_DIR/.release-protection.XXXXXX")"
|
||||
cleanup_probe() { chattr -i "$probe" 2>/dev/null || true; rm -f "$probe"; }
|
||||
trap cleanup_probe EXIT
|
||||
chattr +i "$probe" || { echo 'Filesystem cannot protect legacy entry points; no application was changed' >&2; exit 1; }
|
||||
chattr -i "$probe"
|
||||
backup="$REMOTE_DIR/runtime/private/release-protection/$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
umask 077
|
||||
mkdir -p "$backup"
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
|
||||
continue
|
||||
fi
|
||||
[ ! -f "$REMOTE_DIR/$legacy" ] || cp -p "$REMOTE_DIR/$legacy" "$backup/$legacy"
|
||||
install -m 700 "$LEGACY_STUB" "$REMOTE_DIR/.$legacy.next"
|
||||
mv "$REMOTE_DIR/.$legacy.next" "$REMOTE_DIR/$legacy"
|
||||
chattr +i "$REMOTE_DIR/$legacy"
|
||||
cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"
|
||||
done
|
||||
echo 'Legacy activation paths protected; current application and databases were not restarted'
|
||||
@ -1,72 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Sourced by both activation paths after the deployment and synchronization locks.
|
||||
|
||||
caiwu_verify_release_base() {
|
||||
case "$EXPECTED_PREVIOUS_SHA" in
|
||||
none) ;;
|
||||
*[!0-9a-f]*|'') echo 'Missing or invalid checked production revision; update the deployment scripts' >&2; return 1 ;;
|
||||
*) [ "${#EXPECTED_PREVIOUS_SHA}" -eq 40 ] || return 1 ;;
|
||||
esac
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
[ -n "$PREVIOUS_SHA" ] || PREVIOUS_SHA=none
|
||||
if [ "$PREVIOUS_SHA" != none ] && ! printf '%s\n' "$PREVIOUS_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
echo 'Invalid production revision marker; refusing activation' >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$PREVIOUS_SHA" != "$EXPECTED_PREVIOUS_SHA" ]; then
|
||||
echo "Production changed while this release was being prepared: checked=$EXPECTED_PREVIOUS_SHA current=$PREVIOUS_SHA requested=$DEPLOY_SHA. Fetch latest main and retry; the running application was not changed." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A failed/interrupted older activation must not leave the marker ahead of the app.
|
||||
running_sha="$(docker inspect caiwu_app --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
|
||||
if [ "$PREVIOUS_SHA" != none ] && [ "$running_sha" != "$PREVIOUS_SHA" ]; then
|
||||
echo "Running image and production marker disagree; refusing activation: marker=$PREVIOUS_SHA image=$running_sha" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$PREVIOUS_SHA" = none ] && [ -n "$running_sha" ]; then
|
||||
echo 'An application is already running without a production marker; verify its revision before deploying' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
caiwu_install_release_files() {
|
||||
# Each uploader has its own directory. Shared runtime files change only inside
|
||||
# the activation lock and after the production revision has been rechecked.
|
||||
[ "$RELEASE_DIR" != "$REMOTE_DIR" ] || return 0
|
||||
release_files='compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml'
|
||||
for release_file in $release_files; do
|
||||
[ -f "$RELEASE_DIR/$release_file" ] || { echo "Incomplete staged release: $release_file" >&2; return 1; }
|
||||
done
|
||||
sh "$RELEASE_DIR/install-release-protection.sh" "$REMOTE_DIR" || return 1
|
||||
for release_file in $release_files; do
|
||||
case "$release_file" in activate-release.sh|activate-incremental-release.sh) continue ;; esac
|
||||
case "$release_file" in *.sh) release_mode=700 ;; *) release_mode=644 ;; esac
|
||||
[ "$release_file" != config-db-init.sh ] || release_mode=644
|
||||
install -m "$release_mode" "$RELEASE_DIR/$release_file" "$REMOTE_DIR/.$release_file.next"
|
||||
mv "$REMOTE_DIR/.$release_file.next" "$REMOTE_DIR/$release_file"
|
||||
done
|
||||
if [ -n "$RELEASE_APP_URL" ]; then
|
||||
"$REMOTE_DIR/update-runtime-url.sh" "$RELEASE_APP_URL" "$REMOTE_DIR"
|
||||
fi
|
||||
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.release-controls-sha.next"
|
||||
mv "$REMOTE_DIR/.release-controls-sha.next" "$REMOTE_DIR/.release-controls-sha"
|
||||
}
|
||||
|
||||
caiwu_record_release_result() {
|
||||
release_exit="$1"
|
||||
[ "$RELEASE_RESULT" != running ] || RELEASE_RESULT=failed
|
||||
[ "$release_exit" -ne 0 ] || RELEASE_RESULT=success
|
||||
# Client identity is declared by the workstation; the SSH source is recorded
|
||||
# separately. Neither field is inferred from the commit author.
|
||||
release_actor="$(printf '%s' "$DEPLOY_ACTOR" | tr -cd 'A-Za-z0-9_.@:-' | cut -c1-160)"
|
||||
release_peer="$(printf '%s' "${SSH_CONNECTION:-local}" | tr -cd 'A-Za-z0-9 .:-' | cut -c1-160)"
|
||||
(
|
||||
umask 077
|
||||
mkdir -p "$REMOTE_DIR/runtime/private"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$RELEASE_STARTED_AT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$release_actor" "$release_peer" \
|
||||
"${PREVIOUS_SHA:-unknown}" "$DEPLOY_SHA" "$RELEASE_RESULT" "$release_exit" "$EXPECTED_PREVIOUS_SHA" \
|
||||
>> "$REMOTE_DIR/runtime/private/deployments.tsv"
|
||||
) || echo 'Could not write the private deployment receipt' >&2
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
# This legacy shared path is intentionally retired and immutable on production.
|
||||
echo 'Legacy activation is disabled. Fetch latest main and use the workstation deployment script with an isolated release directory and checked production revision. The running application was not changed.' >&2
|
||||
exit 1
|
||||
@ -35,14 +35,6 @@
|
||||
|
||||
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
|
||||
|
||||
### 成本价镜像观测
|
||||
|
||||
`business-mirror:sync-jushuitan-cost-price-history --source=snapshot` 仅比较既有 `ops_jst_sku` 的当前成本价,首次读取和新增商品只建立基线。基线写入 `jushuitan_cost_price_observation_baselines`,后续观测变化写入独立的 `jushuitan_cost_price_observations`;状态使用 `business_mirror_table_states` 中的 `jushuitan.product-cost-price-observations`,不共用正式操作历史的同步状态。生产保留的旧 SKU 级 `jushuitan_cost_price_snapshots` 不作覆盖、删除或追溯导入。
|
||||
|
||||
观测任务沿 `_mirror_synced_at + _mirror_key` 索引顺序分批续传,默认每批 500 条、每轮最多 20 批;固定窗口并保留两分钟重叠,基线、事件、游标同事务提交。不进行每轮全表计数。生产任务每十分钟尝试运行,沿用 `/run/caiwu-upstream-sync.lock`,繁忙时跳过。首次基线较大时可由同步节点持有同一互斥锁分批完成。
|
||||
|
||||
普通商品资料独立展示每个源商品最近 20 条镜像价格观测,时间明确表示“发现时间”。`modified` 只是商品更新时间,不能充当真实改价时间;采集间隔内的中间价格、建立基线前的历史无法从当前镜像还原。正式操作历史仍使用默认 `--source=open_api`;月度加权成本及其他财务计算继续只读原正式历史,不使用观测结果。
|
||||
|
||||
## 经营分析副本库(operations_replica)
|
||||
|
||||
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`
|
||||
|
||||
@ -129,22 +129,11 @@ bash scripts/deploy-from-mac.sh
|
||||
8. 验证唯一私有七牛桶的写入、读取和删除,并把旧本机业务上传幂等复制到七牛;不删除本机原件;
|
||||
9. 检查服务器本机后端页面;
|
||||
10. 任一存储验证或页面验证失败时恢复上一个镜像;
|
||||
11. 每次上传使用独立暂存目录;服务器取得发布锁和同步锁后,再核对线上 SHA 与发布机已检查的 SHA,确认未变化才切换配置和应用;
|
||||
11. 使用服务器互斥锁阻止两个人同时切换版本;
|
||||
12. 删除本地临时镜像包并关闭 SSH 隧道;
|
||||
13. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
|
||||
14. 清理 7 天前的本项目构建缓存,并将总量压到最多 3GB。
|
||||
|
||||
### 5.0 多人发布保护
|
||||
|
||||
- 远端 `main` 禁止直接推送和强推,任务分支通过 PR 合并;不额外要求人工审批或新增 CI 门槛。
|
||||
- 发布机先刷新远端 `main`,正式发布必须与最新主线一致,并检查本次版本包含线上提交。
|
||||
- 上传期间若别人已发布,服务器在锁内拒绝本次激活,运行中的应用保持原版本。先 `git fetch`、快进到最新 `main` 并重新发布,不用回滚开关绕过并发校验。
|
||||
- Windows 和 macOS 发布机都必须使用最新脚本。共享根目录的两个旧激活入口已停用并设置 Linux immutable 属性,旧发布机无法用上传覆盖它们;新脚本从每次独立暂存目录执行激活,缺少已检查线上版本参数也会被拒绝。
|
||||
- 运行配置按每次发布独立暂存,核对通过后才在锁内安装;上传文件不会提前覆盖其他发布使用的配置。
|
||||
- 服务器私有 `runtime/private/deployments.tsv` 记录起止 UTC 时间、发布机声明的身份、SSH 来源、前后 SHA、结果、退出码和已检查的 SHA。声明身份及提交作者不能单独证明具体操作者;记录不包含凭据或业务数据。
|
||||
- 安装旧入口保护前自动备份到服务器私有 `runtime/private/release-protection/`。普通发布不解除 immutable 属性;维护这些停用入口时先核对备份和目的,再由维护人员使用 `chattr -i` 处理指定文件。应用镜像回滚不需要启用旧入口。
|
||||
- 仅维护发布控制脚本时,可在发布锁内备份并安装已合并主线的控制脚本,记录独立的 `.release-controls-sha`,不重建应用容器、不修改表示应用镜像版本的 `.deployed-sha`。
|
||||
|
||||
### 5.1 安全软件拦截 regctl 时的降级
|
||||
|
||||
第 6 步默认用项目专用 `regctl`(未签名)把 BuildKit 原生 OCI 分层包推入服务器本机 Registry。少数开发机的 360 安全卫士、金山毒霸等安全软件会拦截未签名程序,表现为 `regctl-v0.11.5.exe` 启动即报“拒绝访问”。
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
|
||||
|
||||
class JushuitanCostPriceObservationRepository
|
||||
{
|
||||
public function __construct(private readonly GenericBusinessMirrorRepository $mirror) {}
|
||||
|
||||
/** 仅当前页每个源商品最近 20 条观测;不混入正式财务取价历史。 */
|
||||
public function attach(array $rows): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$row->jushuitan_cost_price_observations = [];
|
||||
}
|
||||
$db = $this->mirror->connection();
|
||||
if ($rows === [] || ! Schema::connection($db->getName())->hasTable(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)) {
|
||||
return;
|
||||
}
|
||||
$keys = collect($rows)->pluck('_mirror_key')->filter()->unique()->values()->all();
|
||||
if ($keys === []) {
|
||||
return;
|
||||
}
|
||||
$ranked = $db->table(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)
|
||||
->whereIn('source_key', $keys)
|
||||
->select('*')->selectRaw('ROW_NUMBER() OVER (PARTITION BY source_key ORDER BY observed_at DESC, id DESC) AS observation_rank');
|
||||
$events = $db->query()->fromSub($ranked, 'ranked')->where('observation_rank', '<=', 20)
|
||||
->orderBy('observed_at', 'desc')->get()->groupBy('source_key');
|
||||
foreach ($rows as $row) {
|
||||
$row->jushuitan_cost_price_observations = ($events->get($row->_mirror_key ?? '') ?? collect())->map(static fn ($event) => [
|
||||
'id' => $event->id, 'source' => '镜像价格观测(非完整改价历史)',
|
||||
'record_type_label' => '观测到成本价变化', 'effect_start_time' => $event->observed_at,
|
||||
'before_price' => $event->before_price, 'after_price' => $event->after_price,
|
||||
'diff_amount' => (float) $event->after_price - (float) $event->before_price,
|
||||
'operator' => '-', 'operation' => '镜像快照比较',
|
||||
'remark' => '上次观测:' . $event->previous_observed_at . ';本次为发现时间,不是真实改价时间。',
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -141,7 +141,6 @@ class ProductCatalogMirrorRepository
|
||||
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
|
||||
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
|
||||
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
|
||||
app(JushuitanCostPriceObservationRepository::class)->attach($rows);
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
@ -572,7 +571,6 @@ class ProductCatalogMirrorRepository
|
||||
$query = $this->connection->table($productTable)
|
||||
->where('sku_type', 'normal')
|
||||
->select([
|
||||
'_mirror_key',
|
||||
'autoid',
|
||||
'i_id',
|
||||
'sku_id',
|
||||
|
||||
@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
/** 当前镜像的观测记录;不写正式操作日志,也不推测真实改价时间或历史价格。 */
|
||||
class JushuitanCostPriceSnapshotDiffer
|
||||
{
|
||||
public const DATASET_KEY = 'jushuitan.product-cost-price-observations';
|
||||
public const SNAPSHOTS = 'jushuitan_cost_price_observation_baselines';
|
||||
public const OBSERVATIONS = 'jushuitan_cost_price_observations';
|
||||
public const STATES = 'business_mirror_table_states';
|
||||
|
||||
public function __construct(
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
) {}
|
||||
|
||||
public function sync(int $batchSize = 500, int $maxBatches = 20): array
|
||||
{
|
||||
$lock = Cache::lock('jushuitan-cost-price-observations', 1800);
|
||||
if (! $lock->get()) {
|
||||
return ['status' => 'locked', 'rows' => 0, 'changes' => 0];
|
||||
}
|
||||
try {
|
||||
return $this->run(max(1, min(1000, $batchSize)), max(1, min(2000, $maxBatches)));
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function run(int $batchSize, int $maxBatches): array
|
||||
{
|
||||
$db = $this->mirror->connection();
|
||||
$schema = Schema::connection($db->getName());
|
||||
$dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
|| ! $schema->hasTable(self::SNAPSHOTS) || ! $schema->hasTable(self::OBSERVATIONS)) {
|
||||
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
|
||||
}
|
||||
$source = $this->mirror->state($dataset);
|
||||
if (($source['status'] ?? '') !== 'succeeded') {
|
||||
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
|
||||
}
|
||||
$state = $db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->first();
|
||||
$resume = $state && in_array($state->status, ['running', 'failed'], true);
|
||||
$cursor = $resume ? json_decode($state->cursor ?? 'null', true) : null;
|
||||
// 固定窗口、两分钟重叠,兼容源镜像批次时间精度;重复读取不重复记变价。
|
||||
$from = $resume ? $state->window_started_at
|
||||
: ($state?->window_ends_at ? Carbon::parse($state->window_ends_at)->subMinutes(2)->format('Y-m-d H:i:s.u') : null);
|
||||
$until = $resume ? $state->window_ends_at : now()->subSeconds(2)->format('Y-m-d H:i:s.u');
|
||||
$db->table(self::STATES)->updateOrInsert(['dataset_key' => self::DATASET_KEY], [
|
||||
'source_key' => 'operations_replica', 'source_object' => 'mirror.' . $dataset->targetTable(),
|
||||
'target_table' => self::OBSERVATIONS, 'status' => 'running',
|
||||
'run_mode' => $state?->initial_completed_at ? 'incremental' : 'baseline',
|
||||
'generation' => $resume ? $state->generation : (string) Str::uuid(),
|
||||
'window_started_at' => $from, 'window_ends_at' => $until,
|
||||
'cursor' => $cursor ? json_encode($cursor) : null,
|
||||
'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
|
||||
]);
|
||||
$scanned = $changes = 0;
|
||||
try {
|
||||
for ($batch = 0; $batch < $maxBatches; $batch++) {
|
||||
$query = $db->table($dataset->targetTable())
|
||||
->select(['_mirror_key', '_mirror_synced_at', 'sku_id', 'cost_price', 'modified'])
|
||||
->where('sku_type', 'normal')->whereNotNull('cost_price')
|
||||
->where('_mirror_synced_at', '<=', $until);
|
||||
if ($from !== null) {
|
||||
$query->where('_mirror_synced_at', '>=', $from);
|
||||
}
|
||||
if ($cursor) {
|
||||
$query->where(static fn ($q) => $q->where('_mirror_synced_at', '>', $cursor['at'])
|
||||
->orWhere(static fn ($q) => $q->where('_mirror_synced_at', $cursor['at'])->where('_mirror_key', '>', $cursor['key'])));
|
||||
}
|
||||
$rows = $query->orderBy('_mirror_synced_at')->orderBy('_mirror_key')->limit($batchSize)->get();
|
||||
if ($rows->isEmpty()) {
|
||||
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
|
||||
'status' => 'succeeded', 'cursor' => null, 'high_watermark' => json_encode(['at' => $until]),
|
||||
'initial_completed_at' => $state?->initial_completed_at ?? now(),
|
||||
'last_succeeded_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
return ['status' => 'succeeded', 'rows' => $scanned, 'changes' => $changes];
|
||||
}
|
||||
$previous = $db->table(self::SNAPSHOTS)->whereIn('source_key', $rows->pluck('_mirror_key'))->get()->keyBy('source_key');
|
||||
$snapshots = $events = [];
|
||||
$observedAt = now()->format('Y-m-d H:i:s.u');
|
||||
foreach ($rows as $row) {
|
||||
$sku = trim((string) $row->sku_id);
|
||||
if ($sku === '' || ! is_numeric($row->cost_price)) {
|
||||
continue;
|
||||
}
|
||||
$price = number_format((float) $row->cost_price, 4, '.', '');
|
||||
$old = $previous->get($row->_mirror_key);
|
||||
if ($old && $old->sku_id === $sku && number_format((float) $old->cost_price, 4, '.', '') !== $price) {
|
||||
$events[] = [
|
||||
'id' => hash('sha256', $row->_mirror_key . '|' . $old->captured_at . '|' . $observedAt . '|' . $price),
|
||||
'source_key' => $row->_mirror_key, 'sku_id' => $sku,
|
||||
'before_price' => $old->cost_price, 'after_price' => $price,
|
||||
'previous_observed_at' => $old->captured_at, 'observed_at' => $observedAt,
|
||||
'source_modified_at' => $row->modified,
|
||||
];
|
||||
}
|
||||
$snapshots[] = ['source_key' => $row->_mirror_key, 'sku_id' => $sku, 'cost_price' => $price, 'captured_at' => $observedAt];
|
||||
}
|
||||
$last = $rows->last();
|
||||
$cursor = ['at' => $last->_mirror_synced_at, 'key' => $last->_mirror_key];
|
||||
// 价格基线、观测事件和游标同一事务;中断续传不会漏写或重复记账。
|
||||
$db->transaction(function () use ($db, $snapshots, $events, $cursor): void {
|
||||
if ($snapshots) {
|
||||
$db->table(self::SNAPSHOTS)->upsert($snapshots, ['source_key'], ['sku_id', 'cost_price', 'captured_at']);
|
||||
}
|
||||
if ($events) {
|
||||
$db->table(self::OBSERVATIONS)->insert($events);
|
||||
}
|
||||
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
|
||||
'cursor' => json_encode($cursor), 'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
$scanned += $rows->count();
|
||||
$changes += count($events);
|
||||
}
|
||||
return ['status' => 'running', 'rows' => $scanned, 'changes' => $changes];
|
||||
} catch (Throwable $exception) {
|
||||
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
|
||||
'status' => 'failed', 'last_error_code' => 'snapshot_comparison_failed', 'updated_at' => now(),
|
||||
]);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
|
||||
* 种菜损耗(2026-10-19 财务确认的口径):
|
||||
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
|
||||
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
|
||||
* 3. 任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,不论是否匹配到发货都单独列出发给运营;
|
||||
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
|
||||
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
|
||||
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
|
||||
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
|
||||
@ -112,9 +112,7 @@ class PlantingLossCalculator
|
||||
|
||||
public static function isVirtualOrderItem(string $barcode, string $productName): bool
|
||||
{
|
||||
$code = strtoupper(trim($barcode));
|
||||
// 透明挂钩条形码以 GG 开头(如 GG003-SZ / GG-xxx),2026-10-20 财务确认。
|
||||
if (in_array($code, self::VIRTUAL_ORDER_BARCODES, true) || str_starts_with($code, 'GG')) {
|
||||
if (in_array(strtoupper(trim($barcode)), self::VIRTUAL_ORDER_BARCODES, true)) {
|
||||
return true;
|
||||
}
|
||||
foreach (self::VIRTUAL_ORDER_KEYWORDS as $keyword) {
|
||||
@ -213,11 +211,10 @@ class PlantingLossCalculator
|
||||
$deductLines !== [] && $freight === null ? '运费缺失' : '',
|
||||
]));
|
||||
|
||||
// 拍A发B/拍A发A 不论是否匹配到发货都单独列出发给运营(2026-10-20 财务确认);匹配发货只用于挑出真实发货的单。
|
||||
$keyword = self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
|
||||
$keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
|
||||
[$result, $basis] = match (true) {
|
||||
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”' . ($shipment === null ? '(未匹配到发货)' : '')],
|
||||
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
|
||||
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'],
|
||||
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'],
|
||||
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'],
|
||||
$missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'],
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/** 财务端货款对账比对汇总 CSV。 */
|
||||
class SupplierComparisonCsvExporter
|
||||
{
|
||||
private const HEADERS = [
|
||||
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
|
||||
'我方数量', '我方单价', '我方金额',
|
||||
'厂家数量', '厂家单价', '厂家金额',
|
||||
'数量差异', '金额差异', '结算口径', '结算金额',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $detail
|
||||
*/
|
||||
public function download(
|
||||
array $detail,
|
||||
string $supplier,
|
||||
string $month,
|
||||
string $reconciler,
|
||||
): StreamedResponse {
|
||||
$safeSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家';
|
||||
$period = $month !== '' ? preg_replace('/[^0-9-]+/u', '', $month) : '全部月份';
|
||||
$filename = $safeSupplier . '_货款对账比对_' . $period . '_' . now()->format('Ymd_His') . '.csv';
|
||||
$summary = (array) ($detail['summary'] ?? []);
|
||||
$rows = (array) ($detail['rows'] ?? []);
|
||||
|
||||
return response()->streamDownload(function () use ($detail, $rows, $summary, $supplier, $month, $reconciler): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
|
||||
fwrite($csv, "\xEF\xBB\xBF");
|
||||
$this->write($csv, ['供应商', $supplier, '月份', $month !== '' ? $month : '全部月份', '对账人', $reconciler !== '' ? $reconciler : '全部']);
|
||||
$this->write($csv, [
|
||||
'我方数据行数', (int) ($detail['mineRows'] ?? 0),
|
||||
'厂家数据行数', (int) ($detail['theirsRows'] ?? 0),
|
||||
'比对结果条数', count($rows),
|
||||
]);
|
||||
$this->write($csv, [
|
||||
'双方一致', (int) ($summary['match'] ?? 0),
|
||||
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
|
||||
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
|
||||
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
|
||||
]);
|
||||
$this->write($csv, [
|
||||
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
|
||||
'厂家金额合计', $this->money($summary['theirsAmount'] ?? 0),
|
||||
'厂家多算金额', $this->money($summary['supplierOverAmount'] ?? 0),
|
||||
'按口径结算合计', $this->money($summary['settleAmount'] ?? 0),
|
||||
'差异金额', $this->money($summary['differenceAmount'] ?? 0),
|
||||
]);
|
||||
$this->write($csv, []);
|
||||
$this->write($csv, self::HEADERS);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->write($csv, [
|
||||
$row['purchaseNo'] ?? '',
|
||||
$row['date'] ?? '',
|
||||
$row['barcode'] ?? '',
|
||||
$row['supplierBarcode'] ?? '',
|
||||
$row['type'] ?? '',
|
||||
$this->number($row['mineQty'] ?? 0),
|
||||
$this->money($row['mineUnitPrice'] ?? 0),
|
||||
$this->money($row['mineAmount'] ?? 0),
|
||||
$this->number($row['theirsQty'] ?? 0),
|
||||
$this->money($row['theirsUnitPrice'] ?? 0),
|
||||
$this->money($row['theirsAmount'] ?? 0),
|
||||
$this->quantityDifference($row['qtyDifference'] ?? 0),
|
||||
$this->amountDifference($row['amountDifference'] ?? 0),
|
||||
$row['note'] ?? '',
|
||||
$this->money($row['settleAmount'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param resource $csv @param list<mixed> $values */
|
||||
private function write($csv, array $values): void
|
||||
{
|
||||
fputcsv($csv, array_map(fn (mixed $value): string => $this->csvCell($value), $values));
|
||||
}
|
||||
|
||||
private function quantityDifference(mixed $value): string
|
||||
{
|
||||
$difference = (float) $value;
|
||||
if (abs($difference) < 0.005) {
|
||||
return '一致';
|
||||
}
|
||||
|
||||
return '厂家' . ($difference > 0 ? '多 ' : '少 ') . $this->number(abs($difference));
|
||||
}
|
||||
|
||||
private function amountDifference(mixed $value): string
|
||||
{
|
||||
$difference = (float) $value;
|
||||
if (abs($difference) < 0.005) {
|
||||
return '一致';
|
||||
}
|
||||
|
||||
return '厂家' . ($difference > 0 ? '多 ¥' : '少 ¥') . $this->money(abs($difference));
|
||||
}
|
||||
|
||||
private function number(mixed $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
private function money(mixed $value): string
|
||||
{
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
|
||||
private function csvCell(mixed $value): string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if (is_numeric(str_replace(',', '', $text))) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text;
|
||||
}
|
||||
}
|
||||
@ -165,7 +165,7 @@ class SupplierDingTalkNotifier
|
||||
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
|
||||
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
|
||||
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
|
||||
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
|
||||
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
|
||||
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
|
||||
];
|
||||
|
||||
@ -179,29 +179,22 @@ class SupplierDingTalkNotifier
|
||||
$theirsQty = (float) ($row['theirsQty'] ?? 0);
|
||||
$mineAmount = (float) ($row['mineAmount'] ?? 0);
|
||||
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
|
||||
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
|
||||
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
|
||||
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
|
||||
$date = trim((string) ($row['date'] ?? ''));
|
||||
$barcode = trim((string) ($row['barcode'] ?? ''));
|
||||
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
|
||||
|
||||
$lines[] = sprintf(
|
||||
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
|
||||
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
|
||||
$rowOffset + $index + 1,
|
||||
(string) ($row['type'] ?? '差异'),
|
||||
$purchaseNo !== '' ? $purchaseNo : '—',
|
||||
$date !== '' ? $date : '—',
|
||||
$barcode,
|
||||
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
|
||||
number_format((float) ($row['unitPrice'] ?? 0), 2),
|
||||
$this->number($mineQty),
|
||||
number_format($mineAmount, 2),
|
||||
$this->number($theirsQty),
|
||||
number_format($theirsAmount, 2),
|
||||
(string) ($row['note'] ?? ''),
|
||||
);
|
||||
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
|
||||
. '|单价 ¥' . number_format($mineUnitPrice, 2)
|
||||
. '|金额 ¥' . number_format($mineAmount, 2);
|
||||
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
|
||||
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
|
||||
. '|金额 ¥' . number_format($theirsAmount, 2);
|
||||
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
|
||||
@ -1,133 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
/**
|
||||
* 退货记账归属:同一笔退货数据同时供财务端和厂家端展示。
|
||||
*
|
||||
* 备注中出现“记账物流”时,表示该部分不扣厂家、转物流记账;
|
||||
* 当前终版数据由业务先调整厂家净数量/金额,备注保留原始数量及转运数量。
|
||||
*/
|
||||
class SupplierReturnAccounting
|
||||
{
|
||||
/** @param array<string,mixed> $row */
|
||||
public function enrich(array $row): array
|
||||
{
|
||||
$remark = trim((string) ($row['remark'] ?? ''));
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
$isLogistics = preg_match('/记账物流|物流记账|物流.*不记账|不记账.*物流/u', $remark) === 1;
|
||||
$isNoBookkeeping = ! $isLogistics && str_contains($remark, '不记账');
|
||||
$target = $isLogistics ? '物流' : ($isNoBookkeeping ? '不记账' : '厂家');
|
||||
|
||||
$logisticsQty = 0.0;
|
||||
$originalQty = 0.0;
|
||||
if ($isLogistics) {
|
||||
if (preg_match('/\*\s*(\d+(?:\.\d+)?)\s*记账物流/u', $remark, $matches) === 1
|
||||
|| preg_match('/记账物流\s*[×xX*]?\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
|
||||
$logisticsQty = (float) $matches[1];
|
||||
}
|
||||
if (preg_match('/原数量\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
|
||||
$originalQty = (float) $matches[1];
|
||||
}
|
||||
if ($logisticsQty === 0.0 && $originalQty > 0) {
|
||||
$logisticsQty = max(0.0, $originalQty - $this->number($row['qty'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$qty = $this->number($row['qty'] ?? 0);
|
||||
$unitPrice = $this->number($row['unit_price'] ?? $row['guess_unit_price'] ?? 0);
|
||||
$amount = $this->number($row['amount'] ?? $row['guess_amount'] ?? 0);
|
||||
if ($amount === 0.0 && $qty !== 0.0) {
|
||||
$amount = $qty * $unitPrice;
|
||||
}
|
||||
if ($unitPrice === 0.0 && $qty !== 0.0) {
|
||||
$unitPrice = $amount / $qty;
|
||||
}
|
||||
|
||||
$supplierQty = $qty;
|
||||
$supplierAmount = $amount;
|
||||
$amountContainsLogistics = $originalQty > 0
|
||||
&& $logisticsQty > 0
|
||||
&& abs($amount - ($originalQty * $unitPrice)) < 0.01;
|
||||
if ($amountContainsLogistics) {
|
||||
$supplierQty = max(0.0, $originalQty - $logisticsQty);
|
||||
$supplierAmount = max(0.0, $amount - ($logisticsQty * $unitPrice));
|
||||
}
|
||||
|
||||
$row['_accounting_target'] = $target;
|
||||
$row['_accounting_label'] = $isLogistics ? '转物流记账' : ($isNoBookkeeping ? '不记账' : '厂家扣减');
|
||||
$row['_accounting_note'] = $isLogistics
|
||||
? '不扣本厂家,转' . ($title !== '' ? $title : '物流') . '记账'
|
||||
: ($isNoBookkeeping ? '本厂家不记账' : '按厂家退货扣减');
|
||||
$row['_logistics_qty'] = $isLogistics ? $logisticsQty : 0.0;
|
||||
$row['_logistics_amount'] = $isLogistics
|
||||
? round($logisticsQty * $unitPrice, 2)
|
||||
: 0.0;
|
||||
$row['_logistics_company'] = $isLogistics ? $title : '';
|
||||
$row['_supplier_qty'] = round($supplierQty, 2);
|
||||
$row['_supplier_amount'] = round($supplierAmount, 2);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array{count:int,supplier_rows:int,supplier_qty:float,supplier_amount:float,logistics_rows:int,logistics_qty:float,logistics_amount:float,logistics_companies:list<string>,no_bookkeeping_rows:int}
|
||||
*/
|
||||
public function summarize(array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'count' => count($rows),
|
||||
'supplier_rows' => 0,
|
||||
'supplier_qty' => 0.0,
|
||||
'supplier_amount' => 0.0,
|
||||
'logistics_rows' => 0,
|
||||
'logistics_qty' => 0.0,
|
||||
'logistics_amount' => 0.0,
|
||||
'logistics_companies' => [],
|
||||
'no_bookkeeping_rows' => 0,
|
||||
];
|
||||
$companies = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = $this->enrich($row);
|
||||
$target = (string) $row['_accounting_target'];
|
||||
|
||||
if ($target === '不记账') {
|
||||
$summary['no_bookkeeping_rows']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['supplier_rows']++;
|
||||
$summary['supplier_qty'] += (float) $row['_supplier_qty'];
|
||||
$summary['supplier_amount'] += (float) $row['_supplier_amount'];
|
||||
|
||||
if ($target !== '物流') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['logistics_rows']++;
|
||||
$summary['logistics_qty'] += (float) $row['_logistics_qty'];
|
||||
$summary['logistics_amount'] += (float) $row['_logistics_amount'];
|
||||
$company = trim((string) $row['_logistics_company']);
|
||||
if ($company !== '') {
|
||||
$companies[$company] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$summary['supplier_qty'] = round($summary['supplier_qty'], 2);
|
||||
$summary['supplier_amount'] = round($summary['supplier_amount'], 2);
|
||||
$summary['logistics_qty'] = round($summary['logistics_qty'], 2);
|
||||
$summary['logistics_amount'] = round($summary['logistics_amount'], 2);
|
||||
$summary['logistics_companies'] = array_keys($companies);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
|
||||
return is_numeric(str_replace(',', '', $raw)) ? (float) str_replace(',', '', $raw) : 0.0;
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,9 @@ namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
/**
|
||||
* 厂家对账单 vs 我方结算数据:
|
||||
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
|
||||
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
|
||||
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
|
||||
* - 行键 = 条码(去掉厂家编号/版本后缀,如 CJ019312N1097V01 → CJ019312)+ 单价
|
||||
* (同一把钥匙的多行先合并:数量、金额相加);
|
||||
* - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。
|
||||
*/
|
||||
class SupplierStatementMatcher
|
||||
{
|
||||
@ -14,7 +14,7 @@ class SupplierStatementMatcher
|
||||
|
||||
public const TYPE_SUPPLIER_EXTRA = '厂家多';
|
||||
|
||||
public const TYPE_MISMATCH = '数量/金额不一致';
|
||||
public const TYPE_MISMATCH = '金额不一致';
|
||||
|
||||
public const TYPE_SUPPLIER_MISSING = '厂家少';
|
||||
|
||||
@ -22,129 +22,74 @@ class SupplierStatementMatcher
|
||||
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号)
|
||||
* @param list<array<string,mixed>> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount)
|
||||
* @param list<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
|
||||
* @param list<array<string,mixed>> $theirs 厂家对账单(barcode/unitPrice/qty/amount)
|
||||
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
|
||||
*/
|
||||
public function compare(array $mine, array $theirs): array
|
||||
{
|
||||
$mineGrouped = $this->group($mine, ['productCode', 'skuCode'], 'purchaseNo', 'date');
|
||||
$theirsGrouped = $this->group($theirs, ['barcode'], 'purchaseNo', 'createdAt');
|
||||
|
||||
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+商品编码唯一时兜底。
|
||||
$fallbackIndex = [];
|
||||
foreach ($mineGrouped as $key => $row) {
|
||||
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
|
||||
}
|
||||
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
|
||||
$mineGrouped = $this->group($mine, 'skuCode');
|
||||
$theirsGrouped = $this->group($theirs, 'barcode');
|
||||
|
||||
$rows = [];
|
||||
$summary = [
|
||||
'match' => 0,
|
||||
'supplierExtra' => 0,
|
||||
'mismatch' => 0,
|
||||
'supplierMissing' => 0,
|
||||
'mineAmount' => 0.0,
|
||||
'theirsAmount' => 0.0,
|
||||
'supplierOverQty' => 0.0,
|
||||
'supplierOverAmount' => 0.0,
|
||||
'settleAmount' => 0.0,
|
||||
];
|
||||
$summary = ['match' => 0, 'supplierExtra' => 0, 'mismatch' => 0, 'supplierMissing' => 0,
|
||||
'mineAmount' => 0.0, 'settleAmount' => 0.0];
|
||||
|
||||
foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) {
|
||||
$mineRow = $mineGrouped[$key] ?? null;
|
||||
$theirsRow = $theirsGrouped[$key] ?? null;
|
||||
|
||||
$purchaseNo = (string) ($mineRow['purchaseNo'] ?? $theirsRow['purchaseNo'] ?? '');
|
||||
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
|
||||
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
|
||||
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
|
||||
$mineUnitPrice = (float) ($mineRow['unitPrice'] ?? 0);
|
||||
$theirsUnitPrice = (float) ($theirsRow['unitPrice'] ?? 0);
|
||||
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
|
||||
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']);
|
||||
$supplierBarcode = (string) ($theirsRow['barcode'] ?? '');
|
||||
$unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']);
|
||||
$mineQty = (float) ($mineRow['qty'] ?? 0);
|
||||
$mineAmount = (float) ($mineRow['amount'] ?? 0);
|
||||
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
|
||||
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
|
||||
$qtyDifference = $theirsQty - $mineQty;
|
||||
$amountDifference = $theirsAmount - $mineAmount;
|
||||
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
|
||||
$fallbackNote = '';
|
||||
if (($theirsRow['matchedByFallback'] ?? false) === true) {
|
||||
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
|
||||
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
|
||||
$fallbackNote = '厂家未填采购单号,且同日期+商品编码有多条我方数据,无法唯一匹配;';
|
||||
}
|
||||
|
||||
if ($mineRow === null) {
|
||||
$type = self::TYPE_SUPPLIER_EXTRA;
|
||||
$settle = 0.0;
|
||||
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+商品编码,不结算';
|
||||
$note = '我方没有这条数据:不结算,退回厂家修改账单';
|
||||
$summary['supplierExtra']++;
|
||||
} elseif ($theirsRow === null) {
|
||||
$type = self::TYPE_SUPPLIER_MISSING;
|
||||
$settle = 0.0;
|
||||
$note = '厂家漏报:我方数量 ' . $this->number($mineQty)
|
||||
. ',单价 ¥' . number_format($mineUnitPrice, 2)
|
||||
. ',金额 ¥' . number_format($mineAmount, 2)
|
||||
. ';按厂家数据结算(不付款)';
|
||||
$note = '厂家少报了这条数据:按厂家数据结算(不付款)';
|
||||
$summary['supplierMissing']++;
|
||||
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
|
||||
$type = self::TYPE_MATCH;
|
||||
$settle = $mineAmount;
|
||||
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
|
||||
$note = '正常结算';
|
||||
$summary['match']++;
|
||||
} else {
|
||||
$type = self::TYPE_MISMATCH;
|
||||
$settle = min($mineAmount, $theirsAmount);
|
||||
$note = $fallbackNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
|
||||
$note = '金额/数量不一致:按金额低的一方结算';
|
||||
$summary['mismatch']++;
|
||||
}
|
||||
|
||||
$summary['mineAmount'] += $mineAmount;
|
||||
$summary['theirsAmount'] += $theirsAmount;
|
||||
$summary['supplierOverQty'] += max($qtyDifference, 0);
|
||||
$summary['supplierOverAmount'] += max($amountDifference, 0);
|
||||
$summary['settleAmount'] += $settle;
|
||||
|
||||
$rows[] = [
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => $date,
|
||||
'barcode' => $barcode,
|
||||
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
|
||||
'supplierBarcode' => $supplierBarcode === $barcode ? '' : $supplierBarcode,
|
||||
'unitPrice' => $unitPrice,
|
||||
'mineUnitPrice' => $mineUnitPrice,
|
||||
'theirsUnitPrice' => $theirsUnitPrice,
|
||||
'mineQty' => $mineQty,
|
||||
'mineAmount' => $mineAmount,
|
||||
'theirsQty' => $theirsQty,
|
||||
'theirsAmount' => $theirsAmount,
|
||||
'qtyDifference' => round($qtyDifference, 4),
|
||||
'amountDifference' => round($amountDifference, 2),
|
||||
'supplierOverQty' => round(max($qtyDifference, 0), 4),
|
||||
'supplierOverAmount' => round(max($amountDifference, 0), 2),
|
||||
'type' => $type,
|
||||
'settleAmount' => $settle,
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $a, array $b): int => [
|
||||
$a['type'] === self::TYPE_MATCH ? 1 : 0,
|
||||
$a['date'],
|
||||
$a['purchaseNo'],
|
||||
$a['barcode'],
|
||||
] <=> [
|
||||
$b['type'] === self::TYPE_MATCH ? 1 : 0,
|
||||
$b['date'],
|
||||
$b['purchaseNo'],
|
||||
$b['barcode'],
|
||||
]);
|
||||
usort($rows, static fn (array $a, array $b): int => [$a['type'] === self::TYPE_MATCH ? 1 : 0, $a['barcode']]
|
||||
<=> [$b['type'] === self::TYPE_MATCH ? 1 : 0, $b['barcode']]);
|
||||
|
||||
$summary['mineAmount'] = round($summary['mineAmount'], 2);
|
||||
$summary['theirsAmount'] = round($summary['theirsAmount'], 2);
|
||||
$summary['supplierOverQty'] = round($summary['supplierOverQty'], 4);
|
||||
$summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2);
|
||||
$summary['settleAmount'] = round($summary['settleAmount'], 2);
|
||||
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
|
||||
|
||||
@ -152,176 +97,36 @@ class SupplierStatementMatcher
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「采购单号 + 日期 + 商品编码」归组,数量与金额相加。
|
||||
* 按「条码 + 单价」归组,数量与金额相加。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @param list<string> $barcodeFields
|
||||
* @return array<string,array<string,mixed>>
|
||||
* @return array<string,array{barcode:string,unitPrice:float,qty:float,amount:float}>
|
||||
*/
|
||||
private function group(array $rows, array $barcodeFields, string $purchaseField, string $dateField): array
|
||||
private function group(array $rows, string $barcodeField): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$barcode = $this->barcodeValue($row, $barcodeFields);
|
||||
$barcode = trim((string) ($row[$barcodeField] ?? ''));
|
||||
if ($barcode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$barcodeKey = $this->normalizeBarcode($barcode);
|
||||
$purchaseNo = $this->normalizePurchaseNo($row[$purchaseField] ?? '');
|
||||
$date = $this->normalizeDate($row[$dateField] ?? '');
|
||||
$unitPrice = (float) ($row['unitPrice'] ?? 0);
|
||||
$key = $this->matchKey($purchaseNo, $date, $barcodeKey);
|
||||
$key = $this->normalizeBarcode($barcode) . '|' . number_format($unitPrice, 4, '.', '');
|
||||
|
||||
$grouped[$key] ??= [
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => $date,
|
||||
'barcode' => $barcode,
|
||||
'barcodeKey' => $barcodeKey,
|
||||
'supplierBarcode' => in_array('barcode', $barcodeFields, true) ? $barcode : '',
|
||||
'unitPrice' => $unitPrice,
|
||||
'unitPrices' => [],
|
||||
'qty' => 0.0,
|
||||
'amount' => 0.0,
|
||||
'matchedByFallback' => false,
|
||||
'fallbackAmbiguous' => false,
|
||||
];
|
||||
if ($unitPrice > 0) {
|
||||
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
|
||||
}
|
||||
$grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0];
|
||||
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
|
||||
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
foreach ($grouped as &$group) {
|
||||
$group = $this->finalizeGroup($group);
|
||||
}
|
||||
unset($group);
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @param list<string> $fields */
|
||||
private function barcodeValue(array $row, array $fields): string
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
$value = trim((string) ($row[$field] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,mixed>> $groups
|
||||
* @param array<string,list<string>> $fallbackIndex
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function resolveMissingPurchaseNumbers(array $groups, array $fallbackIndex): array
|
||||
{
|
||||
$resolved = [];
|
||||
foreach ($groups as $key => $group) {
|
||||
$targetKey = $key;
|
||||
if ($group['purchaseNo'] === '') {
|
||||
$candidates = $fallbackIndex[$group['date'] . "\0" . $group['barcodeKey']] ?? [];
|
||||
if (count($candidates) === 1) {
|
||||
$targetKey = $candidates[0];
|
||||
$group['matchedByFallback'] = true;
|
||||
} elseif (count($candidates) > 1) {
|
||||
$group['fallbackAmbiguous'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($resolved[$targetKey])) {
|
||||
$resolved[$targetKey] = $this->mergeGroup($resolved[$targetKey], $group);
|
||||
} else {
|
||||
$resolved[$targetKey] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $left @param array<string,mixed> $right */
|
||||
private function mergeGroup(array $left, array $right): array
|
||||
{
|
||||
$left['qty'] += (float) $right['qty'];
|
||||
$left['amount'] += (float) $right['amount'];
|
||||
$left['unitPrices'] += (array) $right['unitPrices'];
|
||||
$left['matchedByFallback'] = (bool) $left['matchedByFallback'] || (bool) $right['matchedByFallback'];
|
||||
$left['fallbackAmbiguous'] = (bool) $left['fallbackAmbiguous'] || (bool) $right['fallbackAmbiguous'];
|
||||
if ($left['supplierBarcode'] === '' && $right['supplierBarcode'] !== '') {
|
||||
$left['supplierBarcode'] = $right['supplierBarcode'];
|
||||
}
|
||||
|
||||
return $this->finalizeGroup($left);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $group @return array<string,mixed> */
|
||||
private function finalizeGroup(array $group): array
|
||||
{
|
||||
$prices = array_keys((array) ($group['unitPrices'] ?? []));
|
||||
if (count($prices) === 1) {
|
||||
$group['unitPrice'] = (float) $prices[0];
|
||||
} elseif ((float) $group['qty'] != 0.0) {
|
||||
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
|
||||
}
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
private function matchKey(string $purchaseNo, string $date, string $barcode): string
|
||||
{
|
||||
return $purchaseNo . "\0" . $date . "\0" . $barcode;
|
||||
}
|
||||
|
||||
private function normalizePurchaseNo(mixed $value): string
|
||||
{
|
||||
$purchaseNo = ltrim(trim((string) $value), "'");
|
||||
|
||||
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): string
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) === 1) {
|
||||
$year = (int) $matches[1];
|
||||
$month = (int) $matches[2];
|
||||
$day = (int) $matches[3];
|
||||
if (checkdate($month, $day, $year)) {
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function describeDifference(float $qtyDifference, float $amountDifference): string
|
||||
{
|
||||
$parts = [];
|
||||
if (abs($qtyDifference) >= 0.005) {
|
||||
$parts[] = '厂家数量' . ($qtyDifference > 0 ? '多 ' : '少 ') . $this->number(abs($qtyDifference));
|
||||
}
|
||||
if (abs($amountDifference) >= 0.005) {
|
||||
$parts[] = '厂家金额' . ($amountDifference > 0 ? '多 ¥' : '少 ¥') . number_format(abs($amountDifference), 2);
|
||||
}
|
||||
|
||||
return $parts === [] ? '数量、金额一致' : implode(',', $parts);
|
||||
}
|
||||
|
||||
private function number(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
/** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */
|
||||
private function normalizeBarcode(string $barcode): string
|
||||
{
|
||||
$normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode);
|
||||
|
||||
return strtoupper($normalized === null || $normalized === '' ? $barcode : $normalized);
|
||||
return $normalized === null || $normalized === '' ? $barcode : $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,7 +80,7 @@ class PlantingLossPageData
|
||||
$sync = $this->repository->syncStatus();
|
||||
$shippingSync = $this->calculator->shippingStatus();
|
||||
$empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0);
|
||||
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$items = [];
|
||||
$error = $this->syncError($sync);
|
||||
|
||||
@ -89,7 +89,6 @@ class PlantingLossPageData
|
||||
$all = $this->calculator->rows($filters['month']);
|
||||
foreach ($all as $row) {
|
||||
$summary['total']++;
|
||||
$summary['matched'] += ($row['shipped_at'] ?? '') !== '' ? 1 : 0;
|
||||
$this->add($summary[$row['loss_result']], $row);
|
||||
}
|
||||
foreach ($this->rows($filters, $all) as $row) {
|
||||
@ -101,7 +100,7 @@ class PlantingLossPageData
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。';
|
||||
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$items = [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,14 +5,13 @@ namespace Modules\EcommerceFinance\View;
|
||||
use App\Http\Controllers\Business\PayablesFinalActions;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
|
||||
/**
|
||||
* 「供应商对账」页面数据:
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
|
||||
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
|
||||
*/
|
||||
class SupplierReconciliationPageData
|
||||
@ -20,19 +19,12 @@ class SupplierReconciliationPageData
|
||||
/** 厂家上传的对账数据表:每页行数(短表 + 翻页)。 */
|
||||
private const PER_PAGE = 15;
|
||||
|
||||
/** 退货明细行较高:每页 10 行,并在页面内固定表头滚动查看。 */
|
||||
private const RETURN_PER_PAGE = 10;
|
||||
|
||||
/** 财务端比对明细:每页 15 条。 */
|
||||
private const COMPARISON_PER_PAGE = 15;
|
||||
|
||||
public function __construct(
|
||||
private readonly SupplierPayableOverrideStore $store,
|
||||
private readonly SupplierStatementStore $statements,
|
||||
private readonly SupplierStatementMatcher $matcher,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierReturnAccounting $returnAccounting,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -93,15 +85,6 @@ class SupplierReconciliationPageData
|
||||
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
foreach ($this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
|
||||
$companyId,
|
||||
) as $row) {
|
||||
$name = trim((string) ($row['supplier'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
}
|
||||
$supplierNames = array_keys($suppliers);
|
||||
sort($supplierNames);
|
||||
|
||||
@ -112,31 +95,17 @@ class SupplierReconciliationPageData
|
||||
}
|
||||
|
||||
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
|
||||
$supplierQuery = trim((string) request()->query('supplier', ''));
|
||||
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
|
||||
$supplierNotFound = $supplierQuery !== '' && $selected === '';
|
||||
$selected = trim((string) request()->query('supplier', ''));
|
||||
$detail = null;
|
||||
if ($selected !== '') {
|
||||
if ($selected !== '' && in_array($selected, $supplierNames, true)) {
|
||||
$detail = $this->detail($mine, $selected, $companyId);
|
||||
$totalRows = count($detail['rows']);
|
||||
$pages = max((int) ceil($totalRows / self::COMPARISON_PER_PAGE), 1);
|
||||
$currentPage = min(max((int) request()->query('compare_page', 1), 1), $pages);
|
||||
$detail['total_rows'] = $totalRows;
|
||||
$detail['page'] = $currentPage;
|
||||
$detail['pages'] = $pages;
|
||||
$detail['per_page'] = self::COMPARISON_PER_PAGE;
|
||||
$detail['rows'] = array_slice(
|
||||
$detail['rows'],
|
||||
($currentPage - 1) * self::COMPARISON_PER_PAGE,
|
||||
self::COMPARISON_PER_PAGE,
|
||||
);
|
||||
} else {
|
||||
$selected = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.supplier-reconciliation.finance-data',
|
||||
'supplierNames' => $supplierNames,
|
||||
'supplierQuery' => $supplierQuery,
|
||||
'supplierNotFound' => $supplierNotFound,
|
||||
'uploads' => $uploads,
|
||||
'months' => $months,
|
||||
'month' => $month,
|
||||
@ -144,9 +113,6 @@ class SupplierReconciliationPageData
|
||||
'reconciler' => $reconciler,
|
||||
'selectedSupplier' => $selected,
|
||||
'detail' => $detail,
|
||||
'financeReturnPanel' => $detail !== null
|
||||
? $this->financeReturnPanel($selected, $companyId, $month, $reconciler)
|
||||
: null,
|
||||
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
|
||||
'listUrl' => route('business.pages.show', array_filter([
|
||||
'section' => 'supplier-reconciliation',
|
||||
@ -178,22 +144,11 @@ class SupplierReconciliationPageData
|
||||
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
foreach ($this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
|
||||
$companyId,
|
||||
) as $row) {
|
||||
$name = trim((string) ($row['supplier'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
}
|
||||
$supplierNames = array_keys($suppliers);
|
||||
sort($supplierNames);
|
||||
|
||||
$supplierQuery = trim((string) request()->query('supplier', ''));
|
||||
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
|
||||
$supplierNotFound = $supplierQuery !== '' && $selected === '';
|
||||
if ($supplierQuery === '') {
|
||||
$selected = trim((string) request()->query('supplier', ''));
|
||||
if ($selected === '' || ! in_array($selected, $supplierNames, true)) {
|
||||
$selected = $supplierNames[0] ?? '';
|
||||
}
|
||||
|
||||
@ -206,8 +161,6 @@ class SupplierReconciliationPageData
|
||||
return [
|
||||
'view' => 'business.pages.supplier-reconciliation.supplier-data',
|
||||
'supplierNames' => $supplierNames,
|
||||
'supplierQuery' => $supplierQuery,
|
||||
'supplierNotFound' => $supplierNotFound,
|
||||
'selectedSupplier' => $selected,
|
||||
'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [],
|
||||
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
|
||||
@ -278,46 +231,6 @@ class SupplierReconciliationPageData
|
||||
return $this->detail($mine, $supplier, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务端比对页的退货面板:与厂家端使用同一终版数据集,并保留月份/对账人筛选。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function financeReturnPanel(
|
||||
string $supplier,
|
||||
int $companyId,
|
||||
string $month,
|
||||
string $reconciler,
|
||||
): array {
|
||||
$allRows = $this->returnRows($supplier, $companyId);
|
||||
$statuses = $this->returnConfirmations->statuses($supplier, $companyId);
|
||||
$rows = [];
|
||||
|
||||
foreach ($allRows as $row) {
|
||||
$rowMonth = substr(trim((string) ($row['send_time'] ?? '')), 0, 7);
|
||||
if ($rowMonth === '') {
|
||||
$rowMonth = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7);
|
||||
}
|
||||
if ($month !== '' && $rowMonth !== $month) {
|
||||
continue;
|
||||
}
|
||||
if ($reconciler !== '' && trim((string) ($row['reconciler'] ?? '')) !== $reconciler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['_status'] = $statuses[(string) ($row['_action_key'] ?? '')] ?? '';
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $this->returnConfirmationPanel(
|
||||
$supplier,
|
||||
$rows,
|
||||
$this->returnFilters([]),
|
||||
count($allRows),
|
||||
'finance_return_page',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $input
|
||||
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
|
||||
@ -355,52 +268,36 @@ class SupplierReconciliationPageData
|
||||
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function returnConfirmationPanel(
|
||||
string $supplier,
|
||||
array $returnRows,
|
||||
array $filters,
|
||||
int $allRows,
|
||||
string $pageQueryKey = 'return_page',
|
||||
): array
|
||||
private function returnConfirmationPanel(string $supplier, array $returnRows, array $filters, int $allRows): array
|
||||
{
|
||||
$total = 0.0;
|
||||
$confirmedAmount = 0.0;
|
||||
$disputedAmount = 0.0;
|
||||
$returnQuantity = 0.0;
|
||||
$confirmedQuantity = 0.0;
|
||||
$disputedQuantity = 0.0;
|
||||
$disputedCount = 0;
|
||||
$confirmedCount = 0;
|
||||
|
||||
$rows = [];
|
||||
foreach ($returnRows as $row) {
|
||||
$row = $this->returnAccounting->enrich($row);
|
||||
$key = (string) ($row['_action_key'] ?? '');
|
||||
$deduction = $row['_accounting_target'] === '不记账'
|
||||
? 0.0
|
||||
: (float) $row['_supplier_amount'];
|
||||
$quantity = (float) $row['_supplier_qty'];
|
||||
$deduction = $this->returnDeduction($row);
|
||||
$status = (string) ($row['_status'] ?? '');
|
||||
|
||||
$total += $deduction;
|
||||
$returnQuantity += $quantity;
|
||||
if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) {
|
||||
$confirmedAmount += $deduction;
|
||||
$confirmedQuantity += $quantity;
|
||||
$confirmedCount++;
|
||||
} elseif ($status === SupplierReturnConfirmation::STATUS_DISPUTED) {
|
||||
$disputedAmount += $deduction;
|
||||
$disputedQuantity += $quantity;
|
||||
$disputedCount++;
|
||||
}
|
||||
|
||||
$rows[] = ['_key' => $key, 'status' => $status, 'deduction' => $deduction] + $row;
|
||||
}
|
||||
|
||||
// 全部退货行按导入顺序分页展示(每页 10 行),不做金额排序。
|
||||
$perPage = self::RETURN_PER_PAGE;
|
||||
// 全部退货行按导入顺序分页展示(每页 15 行),不做金额排序。
|
||||
$perPage = self::PER_PAGE;
|
||||
$pages = max((int) ceil(count($rows) / $perPage), 1);
|
||||
$page = min(max((int) request()->query($pageQueryKey, 1), 1), $pages);
|
||||
$page = min(max((int) request()->query('return_page', 1), 1), $pages);
|
||||
|
||||
$filterQuery = $this->returnFilterQuery($filters);
|
||||
|
||||
@ -413,22 +310,29 @@ class SupplierReconciliationPageData
|
||||
'total_rows' => count($returnRows),
|
||||
'all_rows' => $allRows,
|
||||
'total_amount' => round($total, 2),
|
||||
'return_qty' => round($returnQuantity, 2),
|
||||
'confirmed_qty' => round($confirmedQuantity, 2),
|
||||
'disputed_qty' => round($disputedQuantity, 2),
|
||||
'pending_qty' => round(max($returnQuantity - $confirmedQuantity - $disputedQuantity, 0), 2),
|
||||
'pending_amount' => round(max($total - $confirmedAmount - $disputedAmount, 0), 2),
|
||||
'confirmed_count' => $confirmedCount,
|
||||
'confirmed_amount' => round($confirmedAmount, 2),
|
||||
'disputed_count' => $disputedCount,
|
||||
'disputed_amount' => round($disputedAmount, 2),
|
||||
'accounting_summary' => $this->returnAccounting->summarize($returnRows),
|
||||
'filters' => $filters,
|
||||
'filter_query' => $filterQuery,
|
||||
'is_filtered' => $filterQuery !== [],
|
||||
];
|
||||
}
|
||||
|
||||
/** 扣减金额:金额为空时用预估金额(与采购方记账口径一致)。 */
|
||||
private function returnDeduction(array $row): float
|
||||
{
|
||||
$amount = trim((string) ($row['amount'] ?? ''));
|
||||
if ($amount !== '' && is_numeric(str_replace(',', '', $amount))) {
|
||||
return (float) str_replace(',', '', $amount);
|
||||
}
|
||||
|
||||
$guess = trim((string) ($row['guess_amount'] ?? ''));
|
||||
|
||||
return is_numeric(str_replace(',', '', $guess)) ? (float) str_replace(',', '', $guess) : 0.0;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function detail(array $mine, string $supplier, int $companyId): array
|
||||
{
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
@endphp
|
||||
<div class="pw-page" data-preference-key="planting-loss:{{ company_id() }}:{{ optional(user())->id }}">
|
||||
<div class="pw-heading">
|
||||
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。任务备注或卖家备注写了“拍A发B / 拍A发A”的(不论是否匹配到发货)、以及匹配到的单中只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</p>
|
||||
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</p>
|
||||
<div class="pw-tags">
|
||||
<span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
|
||||
<span class="pw-tag {{ ($plantingShippingSync['code'] ?? '') === 'succeeded' ? '' : 'warn' }}">{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
|
||||
@ -31,18 +31,18 @@
|
||||
<button type="submit" class="pw-primary">查询</button>
|
||||
<a class="pw-button" href="{{ request()->url() }}">重置</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', $exportQuery) }}">导出当前筛选</a>
|
||||
<a class="pw-button" href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement']) }}" title="按种菜报销字段导出当月全部拍A发B/拍A发A">导出拍A发B(发运营)</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT]) }}">导出拍A发B(发运营)</a>
|
||||
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
|
||||
</form>
|
||||
@php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp
|
||||
<div class="pw-metrics">
|
||||
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($plantingSummary['matched']) }} 单</small></div>
|
||||
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($deduct['count'] + $exempt['count'] + $free['count']) }} 单</small></div>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价/运费缺失</b>@endif</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_EXEMPT) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_EXEMPT ? 'is-active' : '' }}"><span>不扣款(拍A发B/拍A发A)</span><strong>{{ number_format($exempt['count']) }}</strong><small>需发运营 · 最终报销 {{ $money($exempt['reimbursement_amount']) }}</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_FREE_GOODS) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_FREE_GOODS ? 'is-active' : '' }}"><span>不扣款(虚拟条码/透明挂钩)</span><strong>{{ number_format($free['count']) }}</strong><small>最终报销 {{ $money($free['reimbursement_amount']) }}</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_UNMATCHED) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_UNMATCHED ? 'is-active' : '' }}"><span>未匹配发货(不计入)</span><strong>{{ number_format($unmatched['count']) }}</strong><small>最终报销 {{ $money($unmatched['reimbursement_amount']) }}</small></a>
|
||||
</div>
|
||||
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,不论是否匹配到发货都单独列出,「导出拍A发B(发运营)」按种菜报销字段导出。④ 发货数据里该交易单号下出现“信誉订单虚拟条码”(X00006/X00002)或透明挂钩(条形码 GG 开头)的整单不扣款(同单号下的其它商品视为合并发货的别的订单);只发了“无实物”商品的也不扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
|
||||
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 发货数据里该交易单号下出现“信誉订单虚拟条码”(X00006/X00002)或“透明挂钩”的整单不扣款(同单号下的其它商品视为合并发货的别的订单);只发了“无实物”商品的也不扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
|
||||
@if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)<div class="pw-warn">发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。</div>@endif
|
||||
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
|
||||
<section class="pw-panel" aria-label="种菜损耗明细">
|
||||
|
||||
@ -865,7 +865,6 @@
|
||||
<div><dt>原始范围</dt><dd data-quote-detail="range"></dd></div>
|
||||
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
|
||||
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
|
||||
<div><dt>系统计费规则</dt><dd data-quote-detail="billing"></dd></div>
|
||||
</dl>
|
||||
<div class="quote-signed" data-quote-signed hidden>
|
||||
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
|
||||
@ -1181,7 +1180,6 @@
|
||||
setDetail('range', text(quote.source_range, '-'));
|
||||
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
|
||||
setDetail('formulas', String(quote.formula_count));
|
||||
setDetail('billing', text(quote.billing_summary, '未设置(运费需人工核算)'));
|
||||
var originals = root.querySelector('[data-stock-original-links]');
|
||||
originals.replaceChildren();
|
||||
(quote.stock_originals || []).forEach(function (file, index, files) {
|
||||
|
||||
@ -92,6 +92,14 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="product-cost-check">
|
||||
<input type="checkbox" name="missing_price" value="1" @checked(($filters['missing_price'] ?? '') === '1')>
|
||||
只看未匹配商品单价
|
||||
</label>
|
||||
<label class="product-cost-check">
|
||||
<input type="checkbox" name="include_excluded" value="1" @checked(($filters['include_excluded'] ?? '') === '1')>
|
||||
包含「-不看」仓
|
||||
</label>
|
||||
<div class="product-cost-actions">
|
||||
<button class="product-cost-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
|
||||
<a class="product-cost-button" href="{{ route('business.financial-operations.product-costs.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
|
||||
@ -105,18 +113,14 @@
|
||||
|
||||
<div class="product-cost-note">
|
||||
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行,
|
||||
只取收入金额有数据且不为 0 的账单行,「种菜金额」有数据(>0)的账单行整条排除、不参与匹配;
|
||||
一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,
|
||||
仓库简称按仓库映射表用「仓储方」匹配「仓库名含主仓」的那条取简称(没有主仓行时认同名仓库,
|
||||
常州简尚家居有限公司对应映射表里的「配件单仓」行),
|
||||
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
|
||||
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
|
||||
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入;
|
||||
套餐/服务占位编码 TCXJ、FUWU 不取数(组合装里拆出的这两个编码同样剔除)。
|
||||
只取收入金额有数据且不为 0 的账单行,一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表,
|
||||
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价,匹配不到时退回「线上代发价格」,
|
||||
商品成本 = 商品数量 × 商品单价。
|
||||
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。
|
||||
定制配件这类明细行,聚水潭把配件件数写进了「商品数量」(例如数量 9524、金额 2000.04,实际单价 0.21),
|
||||
乘成品的月加权/代发单价会放大几百倍;成本单价高于该行明细单价 3 倍时判为「数量与单价口径不一致」,成本留空、不计入合计。
|
||||
页面默认展示全部数据:已匹配/未匹配商品单价的行、仓库映射表里标记为「-不看」的仓
|
||||
(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)都纳入列表与统计。
|
||||
仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示,
|
||||
需要核对时可以勾选「包含『-不看』仓」。
|
||||
</div>
|
||||
|
||||
<div class="product-cost-summary">
|
||||
@ -126,9 +130,9 @@
|
||||
<div class="item {{ ($summary['missing_price_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配商品单价行数</span><b>{{ number_format((int) ($summary['missing_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>未匹配单价的商品数量</span><b>{{ $qty($summary['missing_price_quantity'] ?? null) }}</b></div>
|
||||
<div class="item"><span>其中用线上代发价格兜底</span><b>{{ number_format((int) ($summary['fallback_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>其中用商品资料成本价兜底</span><b>{{ number_format((int) ($summary['catalog_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>数量与单价口径不一致(不计成本)</span><b>{{ number_format((int) ($summary['unit_mismatch_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item {{ ($summary['missing_warehouse_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配仓库简称行数</span><b>{{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>已隐藏的「-不看」仓行数</span><b>{{ number_format((int) ($summary['excluded_rows'] ?? 0)) }}</b></div>
|
||||
</div>
|
||||
|
||||
<div class="product-cost-table-wrap">
|
||||
|
||||
@ -739,86 +739,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 业绩明细:把长口径说明拆成可扫描的规则分组,完整原文默认折叠 */
|
||||
.receipt-method-guide {
|
||||
margin: 0 0 1rem;
|
||||
padding: .9rem 1rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: .75rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.receipt-method-guide__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: .7rem 1.5rem;
|
||||
}
|
||||
|
||||
.receipt-method-guide__section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.receipt-method-guide__section h3 {
|
||||
margin: 0 0 .3rem;
|
||||
color: var(--vip-text, #171717);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.receipt-method-guide__section ul {
|
||||
display: grid;
|
||||
gap: .12rem;
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
color: var(--vip-text-muted, #595959);
|
||||
font-size: .75rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.receipt-method-guide__formula {
|
||||
color: var(--vip-text, #171717);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.receipt-method-guide__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .3rem;
|
||||
}
|
||||
|
||||
.receipt-method-guide__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .08rem .4rem;
|
||||
border: 1px solid #e8dfcd;
|
||||
border-radius: .3rem;
|
||||
background: #fffdf8;
|
||||
color: var(--vip-text-muted, #595959);
|
||||
font-size: .6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.receipt-method-guide__details {
|
||||
margin-top: .7rem;
|
||||
padding-top: .65rem;
|
||||
border-top: 1px solid #eee7da;
|
||||
}
|
||||
|
||||
.receipt-method-guide__details summary {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
cursor: pointer;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.receipt-method-guide__details p {
|
||||
margin: .55rem 0 0;
|
||||
color: var(--vip-text-muted, #595959);
|
||||
font-size: .75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 业绩明细:店铺收款匹配统计条 */
|
||||
.receipt-match-summary {
|
||||
display: flex;
|
||||
@ -854,18 +774,6 @@
|
||||
gap: .35rem 1.25rem;
|
||||
}
|
||||
|
||||
.receipt-match-summary__items--amounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .4rem 1.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.receipt-match-summary__items--amounts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-match-summary__item--lead strong {
|
||||
font-size: 1.25rem;
|
||||
color: #b80000;
|
||||
@ -889,25 +797,6 @@
|
||||
color: var(--vip-text, #1f2937);
|
||||
}
|
||||
|
||||
.receipt-match-summary__item--amount {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.receipt-match-summary__item--amount strong {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.receipt-match-summary__value-unit {
|
||||
margin-left: .15em;
|
||||
color: #8a8377;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.receipt-match-summary__item.is-ok strong {
|
||||
color: var(--vip-success, #137a3c);
|
||||
}
|
||||
@ -1187,7 +1076,7 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
|
||||
<div>
|
||||
<strong>{{ $page['title'] ?? $section['title'] }} · 字段结构</strong>
|
||||
@if (!empty($directory['description']) && ($page['slug'] ?? '') !== 'performance-details')
|
||||
@if (!empty($directory['description']))
|
||||
<p class="directory-table-description">{{ $directory['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@ -1229,57 +1118,6 @@
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (($page['slug'] ?? '') === 'performance-details' && !empty($directory['description']))
|
||||
<section class="receipt-method-guide" aria-label="业绩明细指标口径">
|
||||
<div class="receipt-method-guide__grid">
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>匹配口径</h3>
|
||||
<ul>
|
||||
<li>一行 = 一个店铺 + 一个线上订单号</li>
|
||||
<li>商品金额:主单 → 原单行 → 全部行</li>
|
||||
<li>合单/拆单只挂一次,排除 X0000x 虚拟条码</li>
|
||||
<li>订单实付取订单级实付,再扣虚拟条码行实付</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>金额字段</h3>
|
||||
<ul>
|
||||
<li>收入:总账备注 = 订单收入</li>
|
||||
<li>优惠/冲减:分别取优惠收入、冲减收入,不计入收入</li>
|
||||
<li>退款:单独列示,不参与收入和差额</li>
|
||||
<li>种菜:种菜订单的收入子集,不参与差额</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>差额判定</h3>
|
||||
<ul>
|
||||
<li><span class="receipt-method-guide__formula">差额 = 收入 + 优惠 + 冲减 − 订单实付</span></li>
|
||||
<li>未匹配时按订单实付 = 0 计入差额</li>
|
||||
<li>原因按顺序命中;无明确原因时标「差额待核实」</li>
|
||||
<li>卖家实收 / 应付已付能对平的行,差额按 0 计</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>匹配原因</h3>
|
||||
<div class="receipt-method-guide__chips">
|
||||
<span class="receipt-method-guide__chip">账单无单号</span>
|
||||
<span class="receipt-method-guide__chip">单号格式异常</span>
|
||||
<span class="receipt-method-guide__chip">订单表无此单</span>
|
||||
<span class="receipt-method-guide__chip">订单无实付金额</span>
|
||||
<span class="receipt-method-guide__chip">无收款账单</span>
|
||||
<span class="receipt-method-guide__chip">1688 采销收款</span>
|
||||
<span class="receipt-method-guide__chip">店铺名不一致</span>
|
||||
<span class="receipt-method-guide__chip">同单多笔收款</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="receipt-method-guide__details">
|
||||
<summary>查看完整字段与差额判定规则</summary>
|
||||
<p>{{ $directory['description'] }}</p>
|
||||
</details>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if (($page['slug'] ?? '') === 'performance-details' && !empty($receiptStats))
|
||||
{{-- 匹配统计:账单笔数、成功/未匹配笔数、双方收入合计核对结果 --}}
|
||||
<div class="receipt-match-summary" role="status" aria-label="店铺收款匹配统计">
|
||||
@ -1296,15 +1134,15 @@
|
||||
</div>
|
||||
<div class="receipt-match-summary__group">
|
||||
<div class="receipt-match-summary__title">金额构成</div>
|
||||
<div class="receipt-match-summary__items receipt-match-summary__items--amounts">
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<div class="receipt-match-summary__items">
|
||||
<span class="receipt-match-summary__item"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'], 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'], 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'], 2) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1896,17 +1734,13 @@
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
widgets.forEach(function (widget) { widget.setLoading(true); });
|
||||
window.fetch(endpoint + '?' + currentParams().toString(), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.ok ? response.json() : null;
|
||||
}).then(function (data) {
|
||||
if (!data || !data.facets) {
|
||||
widgets.forEach(function (widget) { widget.setLoading(false); });
|
||||
return;
|
||||
}
|
||||
if (!data || !data.facets) { return; }
|
||||
widgets.forEach(function (widget) {
|
||||
var facet = data.facets[widget.root.getAttribute('data-name')];
|
||||
if (!facet || !facet.values) { return; }
|
||||
@ -1916,10 +1750,8 @@
|
||||
});
|
||||
widget.setOptions(options, facet.counts || {});
|
||||
});
|
||||
widgets.forEach(function (widget) { widget.setLoading(false); });
|
||||
}).catch(function () {
|
||||
// 联动统计失败时保留原有选项,不影响筛选本身。
|
||||
widgets.forEach(function (widget) { widget.setLoading(false); });
|
||||
});
|
||||
}
|
||||
|
||||
@ -1948,8 +1780,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
// 首次加载立即取联动数量;只有勾选变化时才做 250ms 防抖。
|
||||
refresh();
|
||||
schedule();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@ -119,7 +119,6 @@
|
||||
<a href="{{ $finalRows->previousPageUrl() }}">上一页</a>
|
||||
@endif
|
||||
第 {{ $finalRows?->currentPage() ?? 1 }} 页 / 共 {{ $finalRows?->lastPage() ?? 1 }} 页
|
||||
· 每页 {{ $finalRows?->perPage() ?? (in_array($finalDataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50) }} 条
|
||||
@if ($finalRows?->hasMorePages())
|
||||
<a href="{{ $finalRows->nextPageUrl() }}">下一页</a>
|
||||
@endif
|
||||
|
||||
@ -149,7 +149,6 @@
|
||||
var valuesEl = root.querySelector('[data-role="values"]');
|
||||
var candidates = (opts.options || []).slice();
|
||||
var counts = opts.counts || {};
|
||||
var countsLoading = false;
|
||||
var selectedList = [];
|
||||
var keyword = '';
|
||||
var optsEl = null;
|
||||
@ -164,9 +163,7 @@
|
||||
function isSel(v) { return selectedList.indexOf(v) >= 0; }
|
||||
function countText(v) {
|
||||
var total = counts[v];
|
||||
if (total === null || total === undefined) {
|
||||
return countsLoading ? '统计中…' : '';
|
||||
}
|
||||
if (total === null || total === undefined) { return ''; }
|
||||
|
||||
return String(total).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
@ -246,13 +243,8 @@
|
||||
setOptions: function (list, nextCounts) {
|
||||
candidates = (list || []).slice();
|
||||
if (nextCounts) { counts = nextCounts; }
|
||||
countsLoading = false;
|
||||
renderOptions();
|
||||
sync();
|
||||
},
|
||||
setLoading: function (loading) {
|
||||
countsLoading = Boolean(loading);
|
||||
renderOptions();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -778,7 +778,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ((!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) || ($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed') && !$productCatalogError)
|
||||
@if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError)
|
||||
<div
|
||||
class="product-catalog-notice"
|
||||
role="status"
|
||||
@ -786,9 +786,7 @@
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
||||
<span>
|
||||
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed')
|
||||
聚水潭成本价历史最近同步失败,记录可能不完整;镜像价格观测单独展示,不代表完整历史。
|
||||
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
|
||||
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
|
||||
聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。
|
||||
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed')
|
||||
聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。
|
||||
@ -907,14 +905,6 @@
|
||||
<span class="product-catalog-history-count">{{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }}</span>
|
||||
<script id="{{ $jushuitanHistoryId }}" type="application/json">@json($jushuitanHistory, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
|
||||
</div>
|
||||
@php($observations = $row->jushuitan_cost_price_observations ?? [])
|
||||
<div class="product-catalog-history-action">
|
||||
<button type="button" class="product-catalog-icon-button" data-price-history-trigger data-history-target="{{ $jushuitanHistoryId }}-observations" data-history-source="镜像价格观测(发现时间,非真实改价时间)" data-sku-code="{{ $row->sku_id ?? '' }}" data-product-name="{{ $row->name ?? '' }}" data-cost-price="{{ $row->cost_price ?? '' }}" data-work-platform-price="{{ $row->work_platform_price ?? '' }}" aria-label="查看镜像价格观测" title="仅从建立基线后记录价格变化,最多展示最近20条,不代表完整操作历史" @disabled($observations === [])>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">visibility</span>
|
||||
</button>
|
||||
<span class="product-catalog-history-count">观测 {{ count($observations) }} 条(最近20条)</span>
|
||||
<script id="{{ $jushuitanHistoryId }}-observations" type="application/json">@json($observations, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="product-catalog-history-action">
|
||||
|
||||
@ -27,18 +27,12 @@
|
||||
.supplier-reconciliation-page .sr-tag.is-extra { background: #FDF1E2; color: #B4650A; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-missing { background: #EAF2F9; color: #2F6FA8; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-extra td,
|
||||
.supplier-reconciliation-page tr.is-mismatch td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-comparison-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-reconciliation-page .sr-comparison-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-reconciliation-page .sr-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
|
||||
.supplier-reconciliation-page .sr-robot-config { padding: 10px 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; }
|
||||
@ -85,17 +79,12 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sr-field"><span>供应商</span>
|
||||
<input type="search"
|
||||
name="supplier"
|
||||
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
|
||||
list="finance-supplier-options"
|
||||
placeholder="输入厂家名称搜索"
|
||||
autocomplete="off">
|
||||
<datalist id="finance-supplier-options">
|
||||
<select name="supplier" onchange="this.form.submit()">
|
||||
<option value="">请选择厂家</option>
|
||||
@foreach ($supplierNames as $supplier)
|
||||
<option value="{{ $supplier }}"></option>
|
||||
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
|
||||
@endforeach
|
||||
</datalist>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="sr-button is-primary">查询</button>
|
||||
</div>
|
||||
@ -105,16 +94,10 @@
|
||||
<a href="{{ route('vendors.index') }}" class="sr-button">厂家登录账号管理</a>
|
||||
@endcanany
|
||||
</form>
|
||||
@if ($supplierNotFound)
|
||||
<div class="biz-alert biz-alert-error" role="alert">
|
||||
未找到名为「{{ $supplierQuery }}」的供应商,请输入完整名称并从搜索建议中选择。
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
|
||||
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
|
||||
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
|
||||
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
|
||||
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。<br>
|
||||
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
|
||||
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
|
||||
</p>
|
||||
@ -140,12 +123,6 @@
|
||||
</span>
|
||||
<span style="flex:1 1 auto"></span>
|
||||
<a href="{{ $listUrl }}" class="sr-button">← 返回供应商列表</a>
|
||||
<a href="{{ route('business.supplier-reconciliation.comparison.export', array_filter([
|
||||
'supplier' => $selectedSupplier,
|
||||
'month' => $month,
|
||||
'reconciler' => $reconciler,
|
||||
], static fn (mixed $value): bool => $value !== '' && $value !== null)) }}"
|
||||
class="sr-button">导出比对汇总</a>
|
||||
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
|
||||
@if ($differenceCount > 0)
|
||||
<form method="POST"
|
||||
@ -262,15 +239,13 @@
|
||||
<div class="sr-summary">
|
||||
<div class="sr-summary-cell"><span>双方一致</span><b>{{ $detail['summary']['match'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>数量/金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>我方金额合计</span><b>{{ number_format($detail['summary']['mineAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家金额合计</span><b>{{ number_format($detail['summary']['theirsAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家多算金额</span><b>{{ number_format($detail['summary']['supplierOverAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>按口径结算合计</span><b>{{ number_format($detail['summary']['settleAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>差异金额</span><b>{{ number_format($detail['summary']['differenceAmount'], 2) }}</b></div>
|
||||
</div>
|
||||
<div class="sr-scroll sr-comparison-scroll">
|
||||
<div class="sr-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -278,17 +253,12 @@
|
||||
<input id="select-all-differences" type="checkbox" aria-label="选择全部差异">
|
||||
</th>
|
||||
<th scope="col">比对结果</th>
|
||||
<th scope="col">采购单号</th>
|
||||
<th scope="col">日期</th>
|
||||
<th scope="col">我方商品编码 / 厂家条码</th>
|
||||
<th scope="col">条码</th>
|
||||
<th scope="col">单价</th>
|
||||
<th scope="col">我方数量</th>
|
||||
<th scope="col">我方单价</th>
|
||||
<th scope="col">我方金额</th>
|
||||
<th scope="col">厂家数量</th>
|
||||
<th scope="col">厂家单价</th>
|
||||
<th scope="col">我方金额</th>
|
||||
<th scope="col">厂家金额</th>
|
||||
<th scope="col">数量差异(厂家-我方)</th>
|
||||
<th scope="col">金额差异(厂家-我方)</th>
|
||||
<th scope="col">结算口径</th>
|
||||
<th scope="col">结算金额</th>
|
||||
<th scope="col">推送</th>
|
||||
@ -299,7 +269,7 @@
|
||||
@php
|
||||
$tagClass = match ($row['type']) {
|
||||
'厂家多' => 'is-extra',
|
||||
'数量/金额不一致' => 'is-mismatch',
|
||||
'金额不一致' => 'is-mismatch',
|
||||
'厂家少' => 'is-missing',
|
||||
default => 'is-match',
|
||||
};
|
||||
@ -311,41 +281,24 @@
|
||||
form="push-selected-form"
|
||||
type="checkbox"
|
||||
name="selected_rows[]"
|
||||
value="{{ json_encode(['purchaseNo' => $row['purchaseNo'], 'date' => $row['date'], 'barcode' => $row['barcode'], 'type' => $row['type']], JSON_UNESCAPED_UNICODE) }}"
|
||||
value="{{ json_encode(['barcode' => $row['barcode'], 'type' => $row['type'], 'unitPrice' => $row['unitPrice']], JSON_UNESCAPED_UNICODE) }}"
|
||||
aria-label="选择差异 {{ $row['barcode'] }}">
|
||||
@else
|
||||
<span class="sr-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
|
||||
<td>{{ $row['purchaseNo'] !== '' ? $row['purchaseNo'] : '—' }}</td>
|
||||
<td>{{ $row['date'] !== '' ? $row['date'] : '—' }}</td>
|
||||
<td>
|
||||
{{ $row['barcode'] }}
|
||||
@if (! empty($row['supplierBarcode']))
|
||||
<span class="sr-muted" style="font-size:.7rem">(厂家:{{ $row['supplierBarcode'] }})</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsUnitPrice'] !== 0.0 ? number_format($row['theirsUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['qtyDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['qtyDifference'] > 0 ? '多' : '少' }} {{ number_format(abs($row['qtyDifference']), 2) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['amountDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="sr-muted">{{ $row['note'] }}</td>
|
||||
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
|
||||
<td>
|
||||
@ -358,10 +311,9 @@
|
||||
<input type="hidden" name="month" value="{{ $month }}">
|
||||
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
|
||||
<input type="hidden" name="scope" value="row">
|
||||
<input type="hidden" name="purchase_no" value="{{ $row['purchaseNo'] }}">
|
||||
<input type="hidden" name="date" value="{{ $row['date'] }}">
|
||||
<input type="hidden" name="barcode" value="{{ $row['barcode'] }}">
|
||||
<input type="hidden" name="difference_type" value="{{ $row['type'] }}">
|
||||
<input type="hidden" name="unit_price" value="{{ number_format($row['unitPrice'], 4, '.', '') }}">
|
||||
<button type="submit"
|
||||
class="sr-button is-small {{ $robot['configured'] ? 'is-primary' : 'is-disabled' }}"
|
||||
@disabled(! $robot['configured'])
|
||||
@ -375,152 +327,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="16"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
<tr><td colspan="11"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="sr-pagination">
|
||||
@php
|
||||
$comparisonPageCount = max((int) ($detail['pages'] ?? 1), 1);
|
||||
$comparisonPageCurrent = min(max((int) ($detail['page'] ?? 1), 1), $comparisonPageCount);
|
||||
$comparisonPageStart = max(1, min($comparisonPageCurrent - 4, $comparisonPageCount - 8));
|
||||
$comparisonPageEnd = min($comparisonPageCount, $comparisonPageStart + 8);
|
||||
$comparisonPageUrl = fn (int $number): string => route('business.pages.show', array_merge(request()->query(), [
|
||||
'section' => $section['slug'],
|
||||
'page' => $page['slug'],
|
||||
'compare_page' => $number,
|
||||
]));
|
||||
@endphp
|
||||
<span>
|
||||
共 {{ number_format((int) ($detail['total_rows'] ?? 0)) }} 条
|
||||
· 每页 {{ number_format((int) ($detail['per_page'] ?? 15)) }} 条
|
||||
· 第 {{ $comparisonPageCurrent }} / {{ $comparisonPageCount }} 页
|
||||
</span>
|
||||
@if ($comparisonPageCurrent > 1)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent - 1) }}">上一页</a>
|
||||
@endif
|
||||
@if ($comparisonPageStart > 1)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl(1) }}">1</a>
|
||||
@if ($comparisonPageStart > 2) <span>…</span> @endif
|
||||
@endif
|
||||
@for ($number = $comparisonPageStart; $number <= $comparisonPageEnd; $number++)
|
||||
<a class="sr-button is-small {{ $number === $comparisonPageCurrent ? 'is-primary' : '' }}"
|
||||
@if ($number === $comparisonPageCurrent) aria-current="page" @endif
|
||||
href="{{ $comparisonPageUrl($number) }}">{{ $number }}</a>
|
||||
@endfor
|
||||
@if ($comparisonPageEnd < $comparisonPageCount)
|
||||
@if ($comparisonPageEnd < $comparisonPageCount - 1) <span>…</span> @endif
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCount) }}">{{ $comparisonPageCount }}</a>
|
||||
@endif
|
||||
@if ($comparisonPageCurrent < $comparisonPageCount)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent + 1) }}">下一页</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $returnAccounting = $financeReturnPanel['accounting_summary']; @endphp
|
||||
<div class="sr-card">
|
||||
<div class="sr-toolbar">
|
||||
<h2>退货扣减 · 财务与厂家同步</h2>
|
||||
<span class="sr-muted" style="font-size:.75rem">
|
||||
直接读取「应付款终版 → 退货对账」同一数据集;修改退货数量、备注、标题后,财务端和厂家门户同时更新。
|
||||
</span>
|
||||
</div>
|
||||
<div class="sr-summary">
|
||||
<div class="sr-summary-cell"><span>退货行数</span><b>{{ number_format($returnAccounting['count']) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家净扣减</span><b>¥{{ number_format($returnAccounting['supplier_amount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>转物流记账</span><b>{{ number_format($returnAccounting['logistics_qty'], 2) }} 件</b></div>
|
||||
<div class="sr-summary-cell"><span>转物流金额</span><b>¥{{ number_format($returnAccounting['logistics_amount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>不记账行数</span><b>{{ number_format($returnAccounting['no_bookkeeping_rows']) }}</b></div>
|
||||
</div>
|
||||
@if ($returnAccounting['logistics_rows'] > 0)
|
||||
<p class="sr-hint">
|
||||
已识别 {{ number_format($returnAccounting['logistics_rows']) }} 行“不扣本厂家、转物流记账”
|
||||
@if (! empty($returnAccounting['logistics_companies']))
|
||||
,物流公司:{{ implode('、', $returnAccounting['logistics_companies']) }}
|
||||
@endif
|
||||
。厂家端显示相同备注和“转物流记账”标记,厂家扣减金额按调整后的净数量/金额计算。
|
||||
</p>
|
||||
@endif
|
||||
<div class="sr-scroll">
|
||||
<table class="sr-return-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">发货时间</th><th scope="col">条形码</th><th scope="col">商品名称</th>
|
||||
<th scope="col">厂家扣减数量</th><th scope="col">厂家扣减金额</th><th scope="col">记账归属</th>
|
||||
<th scope="col">物流记账数量</th><th scope="col">物流记账金额</th><th scope="col">物流公司</th>
|
||||
<th scope="col">备注</th><th scope="col">快递单号</th><th scope="col">对账人</th><th scope="col">确认状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($financeReturnPanel['rows'] as $row)
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
<td>{{ $row['sku_name'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_qty'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_amount'], 2) }}</td>
|
||||
<td>
|
||||
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : ($row['_accounting_target'] === '不记账' ? 'is-no-bookkeeping' : 'is-match') }}">
|
||||
{{ $row['_accounting_label'] }}
|
||||
</span>
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ $row['_logistics_qty'] > 0 ? number_format($row['_logistics_qty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['_logistics_amount'] > 0 ? number_format($row['_logistics_amount'], 2) : '—' }}</td>
|
||||
<td>{{ $row['_logistics_company'] !== '' ? $row['_logistics_company'] : '—' }}</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>{{ $row['express_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['reconciler'] ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($row['status'] === 'confirmed')
|
||||
<span class="sr-tag is-match">已确认</span>
|
||||
@elseif ($row['status'] === 'disputed')
|
||||
<span class="sr-tag is-mismatch">异议中</span>
|
||||
@else
|
||||
<span class="sr-muted">待确认</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="13"><div class="sr-empty">当前月份和对账人筛选下没有退货数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($financeReturnPanel['pages'] > 1)
|
||||
@php
|
||||
$returnPageCount = $financeReturnPanel['pages'];
|
||||
$returnPageCurrent = $financeReturnPanel['page'];
|
||||
$returnPageStart = max(1, min($returnPageCurrent - 4, $returnPageCount - 8));
|
||||
$returnPageEnd = min($returnPageCount, $returnPageStart + 8);
|
||||
$returnPageUrl = fn (int $number): string => route('business.pages.show', array_filter([
|
||||
'section' => $section['slug'],
|
||||
'page' => $page['slug'],
|
||||
'supplier' => $selectedSupplier,
|
||||
'month' => $month,
|
||||
'reconciler' => $reconciler,
|
||||
'finance_return_page' => $number,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
||||
@endphp
|
||||
<div class="sr-pagination">
|
||||
<span>第 {{ $returnPageCurrent }} / {{ $returnPageCount }} 页(每页 {{ $financeReturnPanel['per_page'] }} 行)</span>
|
||||
@if ($returnPageCurrent > 1)
|
||||
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent - 1) }}">上一页</a>
|
||||
@endif
|
||||
@for ($number = $returnPageStart; $number <= $returnPageEnd; $number++)
|
||||
<a class="sr-button is-small {{ $number === $returnPageCurrent ? 'is-primary' : '' }}"
|
||||
@if ($number === $returnPageCurrent) aria-current="page" @endif
|
||||
href="{{ $returnPageUrl($number) }}">{{ $number }}</a>
|
||||
@endfor
|
||||
@if ($returnPageCurrent < $returnPageCount)
|
||||
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent + 1) }}">下一页</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
<div class="supplier-reconciliation-page">
|
||||
@php
|
||||
$formatReturnQty = static function (mixed $value): string {
|
||||
$quantity = (float) $value;
|
||||
|
||||
return number_format($quantity, abs($quantity - round($quantity)) < 0.00001 ? 0 : 2);
|
||||
};
|
||||
@endphp
|
||||
<style>
|
||||
.supplier-reconciliation-page { display: grid; gap: 16px; }
|
||||
.supplier-reconciliation-page .sr-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-surface, #fff); display: grid; gap: 12px; }
|
||||
@ -37,27 +30,14 @@
|
||||
}
|
||||
.supplier-reconciliation-page .sr-return-banner .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 18px; }
|
||||
.supplier-reconciliation-page .sr-return-status { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-return-summary { display: grid; grid-template-columns: repeat(5, minmax(145px, 1fr)); gap: 12px; }
|
||||
.supplier-reconciliation-page .sr-return-stat { min-width: 0; padding: 16px 18px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 10px; background: #fff; display: grid; gap: 8px; }
|
||||
.supplier-reconciliation-page .sr-return-stat.is-total { border-color: #E4DCCB; background: var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.supplier-reconciliation-page .sr-return-stat > span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-return-stat strong { color: var(--vip-content-ink, #171717); font-size: 1.375rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.supplier-reconciliation-page .sr-return-stat strong small { margin-left: 3px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 500; }
|
||||
.supplier-reconciliation-page .sr-return-stat em { color: var(--vip-content-muted, #595959); font-size: .8125rem; font-style: normal; font-variant-numeric: tabular-nums; }
|
||||
.supplier-reconciliation-page .sr-return-table { min-width: 1420px; }
|
||||
.supplier-reconciliation-page .sr-return-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-reconciliation-page .sr-return-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-reconciliation-page .sr-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-reconciliation-page .sr-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
|
||||
.supplier-reconciliation-page .sr-date-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; grid-column: 1 / -1; margin-top: -2px; }
|
||||
.supplier-reconciliation-page .sr-date-presets > span { margin-right: 2px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
@media (max-width: 960px) {
|
||||
.supplier-reconciliation-page .sr-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.supplier-reconciliation-page .sr-return-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.supplier-reconciliation-page .sr-filter { grid-template-columns: minmax(0, 1fr); }
|
||||
.supplier-reconciliation-page .sr-return-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.supplier-reconciliation-page .sr-type-tag {
|
||||
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
|
||||
@ -66,9 +46,6 @@
|
||||
.supplier-reconciliation-page .sr-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
|
||||
.supplier-reconciliation-page .sr-tag.is-match { background: #ECF4EC; color: #3F7A46; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-inline-form { display: inline-flex; }
|
||||
.supplier-reconciliation-page .sr-return-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.supplier-reconciliation-page .sr-return-panel > .sr-return-actions { gap: 12px; }
|
||||
@ -83,31 +60,18 @@
|
||||
|
||||
<div class="sr-card">
|
||||
<h2>厂家对账单 · 上传与实时数据</h2>
|
||||
<form method="GET" class="sr-toolbar" aria-label="查询厂家">
|
||||
<label class="sr-field"><span>厂家名称</span>
|
||||
<input type="search"
|
||||
name="supplier"
|
||||
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
|
||||
list="supplier-name-options"
|
||||
placeholder="输入厂家名称搜索"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<datalist id="supplier-name-options">
|
||||
<form method="GET" class="sr-toolbar" aria-label="选择厂家">
|
||||
<label class="sr-field"><span>厂家</span>
|
||||
<select name="supplier" onchange="this.form.submit()">
|
||||
@foreach ($supplierNames as $supplier)
|
||||
<option value="{{ $supplier }}"></option>
|
||||
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
|
||||
@endforeach
|
||||
</datalist>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="sr-button is-primary">查询厂家</button>
|
||||
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
|
||||
</form>
|
||||
@if ($supplierNotFound)
|
||||
<div class="biz-alert biz-alert-error" role="alert">
|
||||
未找到名为「{{ $supplierQuery }}」的厂家,请输入完整名称并从搜索建议中选择。
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号可以留空;采购单号建议填写(用于按采购单号+日期+条码核对,未填时仅在日期+条码唯一匹配时自动匹配);
|
||||
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空;
|
||||
单价或金额留空时按数量自动补齐(都留空按 0,未定价行可直接导出上传);其余列必填;
|
||||
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
|
||||
</p>
|
||||
@ -124,6 +88,28 @@
|
||||
</div>
|
||||
|
||||
@if ($selectedSupplier !== '')
|
||||
<div class="sr-card">
|
||||
<h2>{{ $selectedSupplier }} · 上传记录</h2>
|
||||
<div class="sr-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($uploads as $upload)
|
||||
<tr>
|
||||
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
|
||||
<td>{{ $upload['filename'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sr-card">
|
||||
<h2>{{ $selectedSupplier }} · 上传的对账数据(共 {{ number_format($statementTotal) }} 行)</h2>
|
||||
<div class="sr-scroll">
|
||||
@ -192,56 +178,28 @@
|
||||
</div>
|
||||
|
||||
<div class="sr-card sr-return-panel">
|
||||
<div class="sr-return-summary" aria-label="退货汇总">
|
||||
<div class="sr-return-stat is-total">
|
||||
<span>退货件数</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['return_qty']) }}<small>件</small></strong>
|
||||
</div>
|
||||
<div class="sr-return-stat is-total">
|
||||
<span>退货金额</span>
|
||||
<strong>¥{{ number_format($returnPanel['total_amount'], 2) }}</strong>
|
||||
</div>
|
||||
<div class="sr-return-stat">
|
||||
<span>已确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['confirmed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['confirmed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sr-return-stat">
|
||||
<span>异议中</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['disputed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['disputed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sr-return-stat">
|
||||
<span>待确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['pending_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['pending_amount'], 2) }}</em>
|
||||
</div>
|
||||
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
|
||||
<div class="sr-return-stat">
|
||||
<span>转物流记账</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['accounting_summary']['logistics_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }}</em>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sr-return-banner" role="note">
|
||||
<span class="material-icons-outlined" aria-hidden="true">info</span>
|
||||
<span>
|
||||
<strong>① 退货扣减确认</strong>
|
||||
@if ($returnPanel['is_filtered'])
|
||||
(当前筛选 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
(筛选出 {{ number_format($returnPanel['total_rows']) }} 行,全部 {{ number_format($returnPanel['all_rows']) }} 行,筛选结果合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
如当前筛选结果核对无误,可点「全部确认当前筛选」;其他退货行不受影响。
|
||||
@else
|
||||
(共 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<div class="sr-return-status">
|
||||
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
|
||||
<span>· 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
|
||||
<span>· 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
|
||||
</div>
|
||||
<form method="GET"
|
||||
action="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
class="sr-filter"
|
||||
aria-label="查询退货明细"
|
||||
data-return-filter-form>
|
||||
aria-label="查询退货明细">
|
||||
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
|
||||
<label class="sr-field"><span>关键字</span>
|
||||
<input type="text" name="return_keyword"
|
||||
@ -250,18 +208,10 @@
|
||||
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
|
||||
</label>
|
||||
<label class="sr-field"><span>发货日期起</span>
|
||||
<input type="date"
|
||||
id="business-return-date-from"
|
||||
name="return_date_from"
|
||||
value="{{ $returnPanel['filters']['date_from'] }}"
|
||||
title="点击选择开始日期">
|
||||
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
|
||||
</label>
|
||||
<label class="sr-field"><span>发货日期止</span>
|
||||
<input type="date"
|
||||
id="business-return-date-to"
|
||||
name="return_date_to"
|
||||
value="{{ $returnPanel['filters']['date_to'] }}"
|
||||
title="点击选择结束日期">
|
||||
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
|
||||
</label>
|
||||
<label class="sr-field"><span>仓库名称</span>
|
||||
<select name="return_warehouse">
|
||||
@ -279,24 +229,17 @@
|
||||
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sr-date-presets" role="group" aria-label="快捷发货日期">
|
||||
<span>快捷日期</span>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="last-7">近 7 天</button>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="this-month">本月</button>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="last-month">上月</button>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="all">全部日期</button>
|
||||
</div>
|
||||
<div class="sr-filter-actions">
|
||||
<button type="submit" class="sr-button is-primary">查询</button>
|
||||
<a class="sr-button"
|
||||
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier]) }}">重置</a>
|
||||
<a class="sr-button"
|
||||
href="{{ route('business.supplier-reconciliation.returns.export', array_merge(['supplier' => $selectedSupplier], $returnPanel['filter_query'])) }}">
|
||||
导出当前筛选结果
|
||||
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="sr-scroll sr-return-scroll">
|
||||
<div class="sr-scroll">
|
||||
<table class="sr-return-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -309,7 +252,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($returnPanel['rows'] as $row)
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<tr>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
@ -318,15 +261,7 @@
|
||||
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
|
||||
<td>
|
||||
{{ $row['remark'] ?? '—' }}
|
||||
@if ($row['_accounting_target'] !== '厂家')
|
||||
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
|
||||
@endif
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>{{ $row['title'] ?? '—' }}</td>
|
||||
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['warehouse'] ?? '—' }}</td>
|
||||
@ -364,9 +299,9 @@
|
||||
<p class="sr-hint">
|
||||
厂家端只展示核对退货所需的字段,左右滑动查看全部。
|
||||
@if ($returnPanel['is_filtered'])
|
||||
当前筛选结果:{{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }},导出包含当前查询条件下的全部数据。
|
||||
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行(全部 {{ number_format($returnPanel['all_rows']) }} 行),导出包含当前查询条件下的全部数据。
|
||||
@else
|
||||
退货共 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }},导出包含该厂家的全部退货数据。
|
||||
退货共 {{ number_format($returnPanel['total_rows']) }} 行,导出包含该厂家的全部退货数据。
|
||||
@endif
|
||||
按导入顺序展示,每页 {{ $returnPanel['per_page'] }} 行(第 {{ $returnPanel['page'] }} / {{ $returnPanel['pages'] }} 页)。
|
||||
</p>
|
||||
@ -422,82 +357,4 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($selectedSupplier !== '')
|
||||
<div class="sr-card">
|
||||
<h2>{{ $selectedSupplier }} · 上传记录</h2>
|
||||
<div class="sr-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($uploads as $upload)
|
||||
<tr>
|
||||
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
|
||||
<td>{{ $upload['filename'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector('[data-return-filter-form]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = form.querySelector('#business-return-date-from');
|
||||
const to = form.querySelector('#business-return-date-to');
|
||||
const iso = (date) => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
[from, to].forEach((input) => {
|
||||
input?.addEventListener('click', () => {
|
||||
if (typeof input.showPicker === 'function') {
|
||||
try {
|
||||
input.showPicker();
|
||||
} catch (error) {
|
||||
// 浏览器已在日期面板中打开时忽略。
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('[data-return-date-range]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
const kind = button.dataset.returnDateRange;
|
||||
|
||||
if (kind === 'all') {
|
||||
from.value = '';
|
||||
to.value = '';
|
||||
} else if (kind === 'this-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
to.value = iso(today);
|
||||
} else if (kind === 'last-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth() - 1, 1));
|
||||
to.value = iso(new Date(today.getFullYear(), today.getMonth(), 0));
|
||||
} else if (kind === 'last-7') {
|
||||
const start = new Date(today);
|
||||
start.setDate(start.getDate() - 6);
|
||||
from.value = iso(start);
|
||||
to.value = iso(today);
|
||||
}
|
||||
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@ -3,13 +3,6 @@
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="supplier-portal-page">
|
||||
@php
|
||||
$formatReturnQty = static function (mixed $value): string {
|
||||
$quantity = (float) $value;
|
||||
|
||||
return number_format($quantity, abs($quantity - round($quantity)) < 0.00001 ? 0 : 2);
|
||||
};
|
||||
@endphp
|
||||
<style>
|
||||
.supplier-portal-page { display: grid; gap: 16px; color: var(--vip-content-ink, #171717); }
|
||||
.supplier-portal-page .sp-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 12px; background: #fff; display: grid; gap: 12px; }
|
||||
@ -35,36 +28,20 @@
|
||||
.supplier-portal-page .sp-alert.is-error { border-color: #E8C4C0; background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-portal-page .sp-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.supplier-portal-page .sp-summary { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-portal-page .sp-return-summary { display: grid; grid-template-columns: repeat(5, minmax(145px, 1fr)); gap: 12px; }
|
||||
.supplier-portal-page .sp-return-stat { min-width: 0; padding: 16px 18px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 10px; background: #fff; display: grid; gap: 8px; }
|
||||
.supplier-portal-page .sp-return-stat.is-total { border-color: #E4DCCB; background: var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.supplier-portal-page .sp-return-stat > span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-portal-page .sp-return-stat strong { color: var(--vip-content-ink, #171717); font-size: 1.375rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.supplier-portal-page .sp-return-stat strong small { margin-left: 3px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 500; }
|
||||
.supplier-portal-page .sp-return-stat em { color: var(--vip-content-muted, #595959); font-size: .8125rem; font-style: normal; font-variant-numeric: tabular-nums; }
|
||||
.supplier-portal-page .sp-return-table { min-width: 1420px; }
|
||||
.supplier-portal-page .sp-return-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-portal-page .sp-return-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-portal-page .sp-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-portal-page .sp-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
|
||||
.supplier-portal-page .sp-date-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; grid-column: 1 / -1; margin-top: -2px; }
|
||||
.supplier-portal-page .sp-date-presets > span { margin-right: 2px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-portal-page .sp-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
|
||||
.supplier-portal-page .sp-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; background: #EFEAE0; color: #595959; font-size: .75rem; white-space: nowrap; }
|
||||
.supplier-portal-page .sp-tag.is-match { background: #ECF4EC; color: #3F7A46; }
|
||||
.supplier-portal-page .sp-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-portal-page .sp-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
|
||||
.supplier-portal-page .sp-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
|
||||
.supplier-portal-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-portal-page .sp-inline-form { display: inline-flex; }
|
||||
.supplier-portal-page .sp-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
@media (max-width: 960px) {
|
||||
.supplier-portal-page .sp-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.supplier-portal-page .sp-return-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.supplier-portal-page .sp-filter { grid-template-columns: minmax(0, 1fr); }
|
||||
.supplier-portal-page .sp-return-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -89,13 +66,18 @@
|
||||
</form>
|
||||
</div>
|
||||
<p class="sp-hint">
|
||||
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号可留空;
|
||||
采购单号建议填写,系统会按采购单号+日期+条码与我方数据核对;
|
||||
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
|
||||
单价或金额留空时会按数量自动补齐。
|
||||
</p>
|
||||
<div class="sp-summary">
|
||||
<span>退货件数:{{ $formatReturnQty($returnPanel['return_qty']) }} 件</span>
|
||||
<span>退货金额:¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
|
||||
@if (! empty($uploads[0]))
|
||||
<span>最近上传:{{ $uploads[0]['uploaded_at'] ?? '—' }}</span>
|
||||
<span>文件:{{ $uploads[0]['filename'] ?? '—' }}</span>
|
||||
<span>行数:{{ number_format((int) ($uploads[0]['rows'] ?? 0)) }}</span>
|
||||
@else
|
||||
<span>当前还没有上传过对账单。</span>
|
||||
@endif
|
||||
<span>退货明细:共 {{ number_format($returnPanel['total_rows']) }} 行</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -104,47 +86,21 @@
|
||||
<p class="sp-hint">
|
||||
这里展示本厂家的全部退货数据(包含待确认、已确认和异议中),按导入顺序排列。
|
||||
@if ($returnPanel['is_filtered'])
|
||||
当前筛选结果:{{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }}。
|
||||
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行。
|
||||
@endif
|
||||
</p>
|
||||
<div class="sp-return-summary" aria-label="退货汇总">
|
||||
<div class="sp-return-stat is-total">
|
||||
<span>退货件数</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['return_qty']) }}<small>件</small></strong>
|
||||
</div>
|
||||
<div class="sp-return-stat is-total">
|
||||
<span>退货金额</span>
|
||||
<strong>¥{{ number_format($returnPanel['total_amount'], 2) }}</strong>
|
||||
</div>
|
||||
<div class="sp-return-stat">
|
||||
<span>已确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['confirmed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['confirmed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sp-return-stat">
|
||||
<span>异议中</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['disputed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['disputed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sp-return-stat">
|
||||
<span>待确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['pending_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['pending_amount'], 2) }}</em>
|
||||
</div>
|
||||
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
|
||||
<div class="sp-return-stat">
|
||||
<span>转物流记账</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['accounting_summary']['logistics_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }}</em>
|
||||
</div>
|
||||
@endif
|
||||
<div class="sp-summary">
|
||||
<span>退货合计 {{ number_format($returnPanel['total_rows']) }} 行</span>
|
||||
<span>扣减合计 ¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
|
||||
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
|
||||
<span>异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
|
||||
<span>待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
|
||||
</div>
|
||||
|
||||
<form method="GET"
|
||||
action="{{ route('portal.supplier-reconciliation.index') }}"
|
||||
class="sp-filter"
|
||||
aria-label="查询退货明细"
|
||||
data-return-filter-form>
|
||||
aria-label="查询退货明细">
|
||||
<label class="sp-field"><span>关键字</span>
|
||||
<input type="text" name="return_keyword"
|
||||
value="{{ $returnPanel['filters']['keyword'] }}"
|
||||
@ -152,18 +108,10 @@
|
||||
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
|
||||
</label>
|
||||
<label class="sp-field"><span>发货日期起</span>
|
||||
<input type="date"
|
||||
id="portal-return-date-from"
|
||||
name="return_date_from"
|
||||
value="{{ $returnPanel['filters']['date_from'] }}"
|
||||
title="点击选择开始日期">
|
||||
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
|
||||
</label>
|
||||
<label class="sp-field"><span>发货日期止</span>
|
||||
<input type="date"
|
||||
id="portal-return-date-to"
|
||||
name="return_date_to"
|
||||
value="{{ $returnPanel['filters']['date_to'] }}"
|
||||
title="点击选择结束日期">
|
||||
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
|
||||
</label>
|
||||
<label class="sp-field"><span>仓库名称</span>
|
||||
<select name="return_warehouse">
|
||||
@ -181,24 +129,17 @@
|
||||
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sp-date-presets" role="group" aria-label="快捷发货日期">
|
||||
<span>快捷日期</span>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="last-7">近 7 天</button>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="this-month">本月</button>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="last-month">上月</button>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="all">全部日期</button>
|
||||
</div>
|
||||
<div class="sp-filter-actions">
|
||||
<button type="submit" class="sp-button is-primary">查询</button>
|
||||
<a class="sp-button" href="{{ route('portal.supplier-reconciliation.index') }}">重置</a>
|
||||
<a class="sp-button"
|
||||
href="{{ route('portal.supplier-reconciliation.returns.export', $returnPanel['filter_query']) }}">
|
||||
导出当前筛选结果
|
||||
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="sp-scroll sp-return-scroll">
|
||||
<div class="sp-scroll">
|
||||
<table class="sp-return-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -211,7 +152,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($returnPanel['rows'] as $row)
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<tr>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
@ -220,15 +161,7 @@
|
||||
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
|
||||
<td>
|
||||
{{ $row['remark'] ?? '—' }}
|
||||
@if ($row['_accounting_target'] !== '厂家')
|
||||
<span class="sp-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
|
||||
@endif
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sp-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>{{ $row['title'] ?? '—' }}</td>
|
||||
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['warehouse'] ?? '—' }}</td>
|
||||
@ -361,58 +294,5 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector('[data-return-filter-form]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = form.querySelector('#portal-return-date-from');
|
||||
const to = form.querySelector('#portal-return-date-to');
|
||||
const iso = (date) => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
[from, to].forEach((input) => {
|
||||
input?.addEventListener('click', () => {
|
||||
if (typeof input.showPicker === 'function') {
|
||||
try {
|
||||
input.showPicker();
|
||||
} catch (error) {
|
||||
// 浏览器已在日期面板中打开时忽略。
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('[data-return-date-range]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
const kind = button.dataset.returnDateRange;
|
||||
|
||||
if (kind === 'all') {
|
||||
from.value = '';
|
||||
to.value = '';
|
||||
} else if (kind === 'this-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
to.value = iso(today);
|
||||
} else if (kind === 'last-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth() - 1, 1));
|
||||
to.value = iso(new Date(today.getFullYear(), today.getMonth(), 0));
|
||||
} else if (kind === 'last-7') {
|
||||
const start = new Date(today);
|
||||
start.setDate(start.getDate() - 6);
|
||||
from.value = iso(start);
|
||||
to.value = iso(today);
|
||||
}
|
||||
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</x-slot>
|
||||
</x-layouts.portal>
|
||||
|
||||
@ -409,10 +409,6 @@ Route::get('workspace/supplier-reconciliation/returns/export', [SupplierReconcil
|
||||
->middleware('business.page:supplier-reconciliation,supplier-data')
|
||||
->name('business.supplier-reconciliation.returns.export');
|
||||
|
||||
Route::get('workspace/supplier-reconciliation/comparison/export', [SupplierReconciliationActions::class, 'exportComparison'])
|
||||
->middleware('business.page:supplier-reconciliation,finance-data')
|
||||
->name('business.supplier-reconciliation.comparison.export');
|
||||
|
||||
// 退货扣减确认:逐行确认/异议 + 一键全部确认
|
||||
Route::post('workspace/supplier-reconciliation/returns/confirm', [SupplierReconciliationActions::class, 'confirmReturn'])
|
||||
->middleware('business.page:supplier-reconciliation,supplier-data')
|
||||
|
||||
@ -416,8 +416,7 @@ if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
fail '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
fi
|
||||
|
||||
git fetch origin main --quiet || fail '无法更新远端 main,已停止发布。'
|
||||
if [ "$branch" = 'main' ]; then
|
||||
if [ "$branch" = 'main' ] && git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
|
||||
fail '本地 main 与 origin/main 不一致,请先执行 git pull --ff-only 再发布。'
|
||||
fi
|
||||
@ -458,6 +457,7 @@ ssh_control_path="/tmp/caiwu-ssh-$$-$RANDOM.sock"
|
||||
ssh_options+=(-o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$ssh_control_path")
|
||||
tunnel_ssh_options=("${ssh_options[@]}" -o ControlPath=none)
|
||||
|
||||
remote_archive="$remote_path/image-$image_tag.tar.gz"
|
||||
temp_root="$(mktemp -d "${TMPDIR:-/tmp}/caiwu-deploy-XXXXXX")"
|
||||
local_tar="$temp_root/image.tar"
|
||||
local_archive="$temp_root/image.tar.gz"
|
||||
@ -512,13 +512,6 @@ for pair in \
|
||||
done
|
||||
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$ssh_target" "cat '$remote_path/.deployed-sha' 2>/dev/null || true" | tr -d '[:space:]')"
|
||||
if [ -n "$previous_sha" ] && ! printf '%s\n' "$previous_sha" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
fail '线上版本标记格式无效,已停止发布。'
|
||||
fi
|
||||
expected_previous_sha="${previous_sha:-none}"
|
||||
deploy_actor="$(printf '%s@%s' "$(git config user.name)" "$(hostname)" | tr -c 'A-Za-z0-9_.@:-' '_')"
|
||||
remote_release_path="$remote_path/releases/$image_tag-$(uuidgen | tr 'A-Z' 'a-z')"
|
||||
remote_archive="$remote_release_path/image-$image_tag.tar.gz"
|
||||
|
||||
if [ -n "$previous_sha" ]; then
|
||||
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
|
||||
@ -563,7 +556,7 @@ if [ "$bootstrap_server" -eq 1 ]; then
|
||||
bootstrap_remote=''
|
||||
fi
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path' '$remote_release_path'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path'" \
|
||||
|| fail '服务器 Docker 未就绪;首次发布请添加 --bootstrap-server。'
|
||||
|
||||
ensure_qiniu_storage_runtime
|
||||
@ -577,9 +570,6 @@ scp_to_remote \
|
||||
"$repo_root/compose.jiangling.yml" \
|
||||
"$repo_root/deploy/activate-release.sh" \
|
||||
"$repo_root/deploy/activate-incremental-release.sh" \
|
||||
"$repo_root/deploy/release-guard.sh" \
|
||||
"$repo_root/deploy/retired-activation.sh" \
|
||||
"$repo_root/deploy/install-release-protection.sh" \
|
||||
"$repo_root/deploy/config-db-init.sh" \
|
||||
"$repo_root/deploy/run-finance-batch.sh" \
|
||||
"$repo_root/deploy/clickhouse-resource-policy.xml" \
|
||||
@ -588,7 +578,10 @@ scp_to_remote \
|
||||
"$repo_root/deploy/host-nginx-8442-dingtalk.conf" \
|
||||
"$repo_root/deploy/setup-incremental-registry.sh" \
|
||||
"$repo_root/deploy/registry-config.yml" \
|
||||
"${ssh_target}:$remote_release_path/" || fail '上传运行配置失败。'
|
||||
"${ssh_target}:$remote_path/" || fail '上传运行配置失败。'
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/update-runtime-url.sh' && '$remote_path/update-runtime-url.sh' '$app_url' '$remote_path'" \
|
||||
|| fail '更新服务器访问地址失败。'
|
||||
|
||||
if [ "$bootstrap_server" -eq 1 ]; then
|
||||
if ! docker image inspect "$database_image" >/dev/null 2>&1; then
|
||||
@ -605,11 +598,11 @@ if [ "$bootstrap_server" -eq 1 ]; then
|
||||
log "首次初始化全量包:${archive_size} MiB"
|
||||
|
||||
scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。'
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_release_path/'*.sh && '$remote_release_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && '$remote_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|
||||
|| fail '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
else
|
||||
ssh "${ssh_options[@]}" -o ConnectTimeout=10 -o ConnectionAttempts=1 "$ssh_target" \
|
||||
"chmod 700 '$remote_release_path/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_release_path/setup-incremental-registry.sh' '$remote_path' '$remote_release_path/registry-config.yml')" \
|
||||
"chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml')" \
|
||||
|| fail '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
docker buildx build \
|
||||
@ -692,7 +685,7 @@ else
|
||||
incremental_mib="$(awk -v a="$after_bytes" -v b="$before_bytes" 'BEGIN { v = (a - b) / 1048576; if (v < 0) v = 0; printf "%.2f", v }')"
|
||||
log "增量新增镜像层:${incremental_mib} MiB;传输耗时:${upload_seconds} 秒"
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_release_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images'" \
|
||||
|| fail '服务器增量激活或健康检查失败。'
|
||||
fi
|
||||
|
||||
|
||||
@ -348,11 +348,6 @@ try {
|
||||
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
}
|
||||
|
||||
Invoke-Native { git fetch origin main --quiet } '无法更新远端 main,已停止发布。'
|
||||
if ($branch -eq 'main' -and (git rev-parse HEAD).Trim() -ne (git rev-parse origin/main).Trim()) {
|
||||
throw '本地 main 不是最新 origin/main,请先 git pull --ff-only 再发布。'
|
||||
}
|
||||
|
||||
$deploySha = (git rev-parse HEAD).Trim()
|
||||
$imageTag = $deploySha.Substring(0, 12)
|
||||
$fullImage = "${imageName}:$imageTag"
|
||||
@ -377,6 +372,7 @@ try {
|
||||
}
|
||||
Invoke-NativeWithRetry { ssh @sshOptions $sshTarget true } '服务器登录失败或已取消,已停止部署。' -Attempts 3
|
||||
if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null }
|
||||
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
|
||||
|
||||
# deploy-auth.ps1 is dot-sourced and can clear the automatic script path
|
||||
# variable on PowerShell Core. Restore it before invoking the build helper.
|
||||
@ -440,11 +436,6 @@ try {
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '无法连接服务器。'
|
||||
}
|
||||
if ($previousSha -and $previousSha -notmatch '^[0-9a-f]{40}$') { throw '线上版本标记格式无效,已停止发布。' }
|
||||
$expectedPreviousSha = if ($previousSha) { $previousSha } else { 'none' }
|
||||
$deployActor = (((git config user.name) -join '') + '@' + [Environment]::MachineName) -replace '[^A-Za-z0-9_.@:-]', '_'
|
||||
$remoteReleasePath = "$RemotePath/releases/$imageTag-$([Guid]::NewGuid().ToString('N'))"
|
||||
$remoteArchive = "$remoteReleasePath/image-$imageTag.tar.gz"
|
||||
|
||||
$previousShaExists = $false
|
||||
if ($previousSha) {
|
||||
@ -496,7 +487,7 @@ try {
|
||||
}
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath' '$remoteReleasePath'"
|
||||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
|
||||
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
|
||||
|
||||
Ensure-QiniuBusinessStorageRuntime
|
||||
@ -507,9 +498,6 @@ try {
|
||||
compose.jiangling.yml `
|
||||
deploy/activate-release.sh `
|
||||
deploy/activate-incremental-release.sh `
|
||||
deploy/release-guard.sh `
|
||||
deploy/retired-activation.sh `
|
||||
deploy/install-release-protection.sh `
|
||||
deploy/config-db-init.sh `
|
||||
deploy/run-finance-batch.sh `
|
||||
deploy/clickhouse-resource-policy.xml `
|
||||
@ -518,9 +506,13 @@ try {
|
||||
deploy/host-nginx-8442-dingtalk.conf `
|
||||
deploy/setup-incremental-registry.sh `
|
||||
deploy/registry-config.yml `
|
||||
"${sshTarget}:$remoteReleasePath/"
|
||||
"${sshTarget}:$RemotePath/"
|
||||
} '上传运行配置失败。'
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
|
||||
} '更新服务器访问地址失败。'
|
||||
|
||||
if ($BootstrapServer) {
|
||||
docker image inspect $DatabaseImage 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@ -540,12 +532,12 @@ try {
|
||||
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
|
||||
Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && '$remoteReleasePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
} '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
}
|
||||
else {
|
||||
Invoke-NativeWithRetry {
|
||||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remoteReleasePath/setup-incremental-registry.sh' '$RemotePath' '$remoteReleasePath/registry-config.yml')"
|
||||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
|
||||
} '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
|
||||
@ -661,7 +653,7 @@ try {
|
||||
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "'$remoteReleasePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl'"
|
||||
ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
|
||||
} '服务器增量激活或健康检查失败。'
|
||||
}
|
||||
|
||||
|
||||
@ -8,20 +8,16 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `toutiao` | 头条放心购 → 资金流水 | `business-mirror:import-douyin-authoritative` | 账单流水数据 › 店铺收款账户账单 › **抖音账户** |
|
||||
| `jd` | 京东商城 → 京东日账单 | `business-mirror:import-jd-daily` | **京东账单 + 京喜账单**(一次导入按店铺名拆两边,两边合计恒等于导出行数) |
|
||||
| `jd-wallet` | 京东商城 → 京东钱包 | `business-mirror:import-jd-wallet` | **京东网银钱包账户** |
|
||||
|
||||
## 用法
|
||||
|
||||
```powershell
|
||||
# 三个平台都跑:区间「月初 → 昨日」
|
||||
# 两个平台都跑:区间「月初 → 昨日」
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1
|
||||
|
||||
# 只跑京东,补历史月份
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd -Through 2026-07-31
|
||||
|
||||
# 只跑京东钱包,补历史月份
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31
|
||||
|
||||
# 只导出不导入(排查)
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through 2026-10-05 -SkipImport
|
||||
```
|
||||
@ -30,19 +26,9 @@ pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through
|
||||
- **无差异跳过**:该平台该月已导入且笔数/收入/支出完全一致时跳过导入(`-ForceImport` 可强制重刷);状态记在 `runtime/private/erp-exports/imported-state.json`。
|
||||
- 超过胜算同步导出上限(响应 `超出同步导出允许最大数据量`)时,脚本自动轮询「异步导出下载管理」取回本次任务的全部分片,逐片转 CSV 后合并成一份再导入;仍可用 `-Start/-Through` 分段导出。
|
||||
- 导入前用页面笔数(共 N 条)与导出行数核对,不一致就跳过导入(`-ForceImport` 可强制),避免只导入一部分。
|
||||
- 稳定性:导出最多重试 3 次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。
|
||||
- 稳定性:导出失败自动重试一次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。
|
||||
- 失败即失败:导出/转 CSV/笔数核对/上传/导入回验任一步失败,都会**不写本地状态、不归档**,并在结束时以退出码 1 结束(下次运行会重新导入),不会把失败当成功记入 `imported-state.json`。
|
||||
|
||||
## 覆盖校验
|
||||
|
||||
导入后可核对三个数据集在指定区间内是否逐月覆盖到截止日:
|
||||
|
||||
```powershell
|
||||
docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-coverage --from=2026-07-01 --to=2026-10-19
|
||||
```
|
||||
|
||||
命令会逐个月份检查期间起止范围、有效版本行数和登记行数;缺月、只导到月中或行数不一致都会返回失败。
|
||||
|
||||
## 组成
|
||||
|
||||
| 文件 | 作用 |
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
*
|
||||
* --platform=toutiao 头条放心购 → 资金流水 (导入 import-douyin-authoritative → 抖音账户)
|
||||
* --platform=jd 京东商城 → 京东日账单 (导入 import-jd-daily → 京东账单 + 京喜账单,按店铺名拆分)
|
||||
* --platform=jd-wallet 京东商城 → 京东钱包 (导入 import-jd-wallet → 京东网银钱包账户)
|
||||
*
|
||||
* 用法:
|
||||
* node export-daily.mjs --platform=toutiao # 本月月初 → 昨日
|
||||
@ -25,7 +24,6 @@ const BASE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PLATFORMS = {
|
||||
toutiao: { label: '头条放心购', subTab: '资金流水', filePrefix: '头条放心购_资金流水', exportMainName: '头条放心购头条资金流水' },
|
||||
jd: { label: '京东商城', subTab: '京东日账单', filePrefix: '京东日账单', exportMainName: '京东商城京东日账单' },
|
||||
'jd-wallet': { label: '京东商城', subTab: '京东钱包', filePrefix: '京东钱包', exportMainName: '京东商城京东钱包' },
|
||||
};
|
||||
const arg = (name, fallback = null) => {
|
||||
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
|
||||
@ -43,7 +41,7 @@ const BILL_URL = 'https://ss.erp321.com/process/platform-bill-new';
|
||||
const platformKey = arg('platform', 'toutiao');
|
||||
const platform = PLATFORMS[platformKey];
|
||||
if (!platform) {
|
||||
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd / jd-wallet)');
|
||||
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd)');
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
@ -1,21 +1,19 @@
|
||||
<#
|
||||
聚胜算每日账单 → 财务系统(统一入口)
|
||||
|
||||
平台:toutiao = 头条放心购→资金流水(落 抖音账户)
|
||||
jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单)
|
||||
jd-wallet = 京东商城→京东钱包(落 京东网银钱包账户)
|
||||
平台:toutiao = 头条放心购→资金流水(落 抖音账户)
|
||||
jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单)
|
||||
|
||||
流程:导出 → xlsx 转 CSV → 与已导入期间比对(无差异则跳过)→ 上传 → 受控导入 → 页面回验 → 归档 → 写日志
|
||||
|
||||
用法:
|
||||
pwsh -File run-erp-daily.ps1 # 三个平台,区间「月初 → 昨日」
|
||||
pwsh -File run-erp-daily.ps1 # 两个平台,区间「月初 → 昨日」
|
||||
pwsh -File run-erp-daily.ps1 -Platform jd -Through 2026-07-31
|
||||
pwsh -File run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31
|
||||
pwsh -File run-erp-daily.ps1 -Platform toutiao -SkipImport
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('toutiao', 'jd', 'jd-wallet', 'both')][string]$Platform = 'both',
|
||||
[ValidateSet('toutiao', 'jd', 'both')][string]$Platform = 'both',
|
||||
[string]$Through,
|
||||
[string]$Start,
|
||||
[switch]$SkipImport,
|
||||
@ -47,10 +45,9 @@ function Write-Log {
|
||||
$specs = @{
|
||||
toutiao = @{ label = '头条放心购'; artifact = 'douyin'; importCmd = 'business-mirror:import-douyin-authoritative'; verifyTabs = @('douyin'); split = $false }
|
||||
jd = @{ label = '京东日账单'; artifact = 'jd'; importCmd = 'business-mirror:import-jd-daily'; verifyTabs = @('jd-transactions', 'jingxi'); split = $true }
|
||||
'jd-wallet' = @{ label = '京东钱包'; artifact = 'jd-wallet'; importCmd = 'business-mirror:import-jd-wallet'; verifyTabs = @('jd-wallet'); split = $false }
|
||||
}
|
||||
|
||||
$targets = if ($Platform -eq 'both') { @('toutiao', 'jd', 'jd-wallet') } else { @($Platform) }
|
||||
$targets = if ($Platform -eq 'both') { @('toutiao', 'jd') } else { @($Platform) }
|
||||
$state = if (Test-Path -LiteralPath $stateFile) { (Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json) } else { New-Object psobject }
|
||||
|
||||
$auth = Start-CaiwuDeploymentAuth -Server '218.93.190.75' -SshUser 'root'
|
||||
@ -147,7 +144,7 @@ try {
|
||||
# 2) 无差异跳过
|
||||
$key = $target + ':' + $month
|
||||
$prev = $state.$key
|
||||
$same = $prev -and ([string]$prev.through -eq [string]$summary.through) -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005)
|
||||
$same = $prev -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005)
|
||||
if ($same -and -not $ForceImport) {
|
||||
Write-Log ('该月已导入且无差异({0} 笔 / {1} / {2}),跳过导入' -f $rows, $income, $expense)
|
||||
continue
|
||||
@ -173,11 +170,9 @@ require '/var/www/html/vendor/autoload.php';
|
||||
`$repo = app(Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository::class);
|
||||
`$tabs = ['__TABS__'];
|
||||
foreach (`$tabs as `$tab) {
|
||||
`$r = match (`$tab) {
|
||||
'douyin' => `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
|
||||
'jd-wallet' => app(Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
|
||||
default => app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab),
|
||||
};
|
||||
`$r = `$tab === 'douyin'
|
||||
? `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1)
|
||||
: app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab);
|
||||
echo 'PAGE ' . `$tab . ' total=' . `$r['total'] . ' ' . json_encode(`$r['summary'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
}
|
||||
PHP
|
||||
@ -202,14 +197,12 @@ rm -f /tmp/daily_bills.csv $remoteFile
|
||||
}
|
||||
|
||||
# 4) 记录状态 + 归档
|
||||
$state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; through = $summary.through; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force
|
||||
$state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force
|
||||
$state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $stateFile -Encoding UTF8
|
||||
|
||||
$monthNumber = [int]$month.Substring(5, 2)
|
||||
$shareDir = Join-Path 'Z:\高祝兰\财务系统' ('{0}月' -f $monthNumber)
|
||||
$shareDir = 'Z:\高祝兰\财务系统\9月'
|
||||
if (Test-Path -LiteralPath 'Z:\高祝兰\财务系统') {
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $shareDir -Force | Out-Null
|
||||
foreach ($f in $rawFiles) {
|
||||
Copy-Item -LiteralPath $f -Destination (Join-Path $shareDir ([IO.Path]::GetFileName($f))) -Force
|
||||
}
|
||||
|
||||
@ -1,289 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Support\CourierQuoteFreightCalculator;
|
||||
use App\Support\CourierQuoteStructure;
|
||||
use App\Support\QuoteManagementSnapshot;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierQuoteBillingRulesTest extends FeatureTestCase
|
||||
{
|
||||
private array $files = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** 用各家原表备注里自带的算例核对计费口径。 */
|
||||
public function testFreightMatchesWorkedExamplesFromQuoteRemarks(): void
|
||||
{
|
||||
$f = fn (array $structure, array $row, float $kg, string $rounding = 'none') => CourierQuoteStructure::freight(
|
||||
$structure, $row, CourierQuoteStructure::roundWeight($kg, $rounding)
|
||||
)['amount'];
|
||||
|
||||
// 江西新建中通:15kg 广东 = 5.5 + 10×0.43 + 5×0.63(累进,用分段表示)
|
||||
$progressive = ['mode' => 'segments'];
|
||||
$gd = ['region' => '广东', 'segments' => [
|
||||
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
|
||||
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
|
||||
]];
|
||||
$this->assertEqualsWithDelta(12.95, $f($progressive, $gd, 15), 0.0001);
|
||||
$this->assertEqualsWithDelta(9.8, $f($progressive, $gd, 10), 0.0001);
|
||||
|
||||
// 迈当极兔:8.1kg 广东向上取整按 9kg,3 + 9×1.1 = 12.9;3kg 内一口价 4.2
|
||||
$md = ['mode' => 'flat_then', 'flat_bands' => [3], 'then_mode' => 'waybill_rate', 'rate_tiers' => [10]];
|
||||
$mdRow = ['region' => '广东', 'flat' => [4.2], 'base' => 3, 'rates' => [1.1, 1.4]];
|
||||
$this->assertEqualsWithDelta(12.9, $f($md, $mdRow, 8.1, 'ceil'), 0.0001);
|
||||
$this->assertEqualsWithDelta(4.2, $f($md, $mdRow, 2.6, 'ceil'), 0.0001);
|
||||
$this->assertEqualsWithDelta(3 + 11 * 1.4, $f($md, $mdRow, 10.2, 'ceil'), 0.0001);
|
||||
|
||||
// 顺兴中通快运:10.4kg = (10.4-10)×1.07 + 首重最低收费 24
|
||||
$sx = ['mode' => 'first_additional', 'first_weight' => 10, 'rate_tiers' => []];
|
||||
$this->assertEqualsWithDelta(24.428, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 10.4), 0.0001);
|
||||
$this->assertEqualsWithDelta(24.0, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 6), 0.0001);
|
||||
|
||||
// 彩艺德邦:四舍五入,4.4 按 4kg(首重 5kg 内 13 元),5.5 按 6kg
|
||||
$cy = ['mode' => 'first_additional', 'first_weight' => 5, 'rate_tiers' => []];
|
||||
$cyRow = ['region' => '江苏', 'base' => 13, 'rates' => [1.6]];
|
||||
$this->assertEqualsWithDelta(13.0, $f($cy, $cyRow, 4.4, 'round'), 0.0001);
|
||||
$this->assertEqualsWithDelta(14.6, $f($cy, $cyRow, 5.5, 'round'), 0.0001);
|
||||
|
||||
// 常州圆通:按总重量所在区间取费率,区间上限含本数
|
||||
$yt = ['mode' => 'waybill_rate', 'rate_tiers' => [35, 50]];
|
||||
$ytRow = ['region' => '江苏', 'base' => 3.6, 'rates' => [0.48, 0.98, 1.28]];
|
||||
$this->assertEqualsWithDelta(3.6 + 35 * 0.48, $f($yt, $ytRow, 35), 0.0001);
|
||||
$this->assertEqualsWithDelta(3.6 + 35.5 * 0.98, $f($yt, $ytRow, 35.5), 0.0001);
|
||||
$this->assertEqualsWithDelta(3.6 + 60 * 1.28, $f($yt, $ytRow, 60), 0.0001);
|
||||
|
||||
// 派雅极兔:某一段原表为「不发」(价格为空);超过最后一段不在报价范围
|
||||
$py = ['mode' => 'flat_bands', 'flat_bands' => [3, 5]];
|
||||
$pyRow = ['region' => '江苏', 'flat' => [4, null]];
|
||||
$this->assertEqualsWithDelta(4.0, $f($py, $pyRow, 2), 0.0001);
|
||||
$this->assertSame('该重量段不发', CourierQuoteStructure::freight($py, $pyRow, 4)['reason']);
|
||||
$this->assertSame('超出报价重量范围', CourierQuoteStructure::freight($py, $pyRow, 6)['reason']);
|
||||
|
||||
$this->assertSame('特殊计费,需人工核算', CourierQuoteStructure::freight(['mode' => 'other'], [], 3)['reason']);
|
||||
}
|
||||
|
||||
public function testImportCommandWritesRulesAndCalculatorUsesThemByShipDate(): void
|
||||
{
|
||||
$old = $this->version('QB-101', '常州圆通其他', 'CZYTOQT', '2026-06-01', '2026-09-19');
|
||||
$new = $this->version('QB-102', '常州圆通其他', 'CZYTOQT', '2026-09-20', null);
|
||||
$seg = $this->version('QB-103', '江西新建中通', 'JXXJZTO', '2000-01-01', null);
|
||||
$manual = $this->version('QB-104', '华韵安能', 'HYAN', '2025-07-01', null);
|
||||
$file = $this->rulesFile([
|
||||
$this->waybill('QB-101', '常州圆通其他', 3.5),
|
||||
$this->waybill('QB-102', '常州圆通其他', 3.6),
|
||||
[
|
||||
'version_id' => 'QB-103', 'company_name' => '江西新建中通', 'conversion' => 'needs_segment_mode',
|
||||
'structure' => ['mode' => 'other', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [], 'rounding' => 'none'],
|
||||
'segments_rows' => [['region' => '广东', 'segments' => [
|
||||
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
|
||||
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
|
||||
]]],
|
||||
'params' => ['volumetric_divisor' => 10000, 'weight_limit_kg' => 45, 'surcharges' => [], 'notes' => []],
|
||||
],
|
||||
['version_id' => 'QB-104', 'company_name' => '华韵安能', 'conversion' => 'manual', 'manual_reason' => '按体积计费'],
|
||||
]);
|
||||
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id, '--dry-run' => true])
|
||||
->assertExitCode(0);
|
||||
$this->assertNull($old->fresh()->billing_structure);
|
||||
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])
|
||||
->assertExitCode(0);
|
||||
$this->assertSame('waybill_rate', $old->fresh()->billing_structure['mode']);
|
||||
$this->assertEquals([35, 50], $new->fresh()->billing_structure['rate_tiers']);
|
||||
$this->assertSame('segments', $seg->fresh()->billing_structure['mode']);
|
||||
$this->assertNull($manual->fresh()->billing_structure);
|
||||
|
||||
$calc = new CourierQuoteFreightCalculator();
|
||||
// 发货日期决定用哪一版:9 月 1 日走旧版面单 3.5,9 月 25 日走新版面单 3.6
|
||||
$before = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-01'), '江苏省', 10);
|
||||
$this->assertSame('QB-101', $before['version_code']);
|
||||
$this->assertEqualsWithDelta(3.5 + 10 * 0.48, $before['amount'], 0.001);
|
||||
$after = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10);
|
||||
$this->assertSame('QB-102', $after['version_code']);
|
||||
$this->assertEqualsWithDelta(3.6 + 10 * 0.48, $after['amount'], 0.001);
|
||||
|
||||
// 计泡:60×50×40/6000 = 20kg > 实重 10kg
|
||||
$volume = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10, 60, 50, 40);
|
||||
$this->assertEqualsWithDelta(20.0, $volume['volume_weight'], 0.001);
|
||||
$this->assertEqualsWithDelta(3.6 + 20 * 0.48, $volume['amount'], 0.001);
|
||||
|
||||
// 城市单列行优先:深圳走「深圳(广东)」,其他广东城市走广东
|
||||
$this->assertEqualsWithDelta(3.6 + 2 * 1.0, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '深圳')['amount'], 0.001);
|
||||
$this->assertEqualsWithDelta(3.6 + 2 * 0.75, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '广州')['amount'], 0.001);
|
||||
|
||||
$this->assertEqualsWithDelta(12.95, $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
|
||||
// 没有快递编码的存量报价按快递名称查
|
||||
$seg->forceFill(['courier_expcode' => null])->save();
|
||||
$this->assertEqualsWithDelta(12.95, (new CourierQuoteFreightCalculator())->calculate('江西新建中通', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
|
||||
$seg->forceFill(['courier_expcode' => 'JXXJZTO'])->save();
|
||||
|
||||
// 起算重量:靓阁不足 16kg 按 16kg(落在 16-17kg 段 40 元)
|
||||
$this->version('QB-105', '靓阁快递', 'LGKD', '2024-03-12', null)->forceFill([
|
||||
'billing_structure' => ['mode' => 'flat_bands', 'flat_bands' => [17.0, 18.0], 'rounding' => 'none', 'min_weight' => 16.0, 'weight_limit' => 45.0],
|
||||
'billing_rows' => [['region' => '江苏', 'flat' => [40.0, 50.0], 'base' => null, 'rates' => []]],
|
||||
])->save();
|
||||
$light = $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 3);
|
||||
$this->assertSame(16.0, $light['billable_weight']);
|
||||
$this->assertEqualsWithDelta(40.0, $light['amount'], 0.001);
|
||||
$this->assertEqualsWithDelta(50.0, $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 17.5)['amount'], 0.001);
|
||||
$this->assertSame('超出限重 45kg', $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 46)['reason']);
|
||||
$this->assertStringContainsString('未覆盖目的地', $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '西藏', 2)['reason']);
|
||||
$this->assertStringContainsString('需人工核算', $calc->calculate('HYAN', Carbon::parse('2026-09-25'), '江苏', 2)['reason']);
|
||||
$this->assertSame('发货日期没有生效的报价', $calc->calculate('CZYTOQT', Carbon::parse('2026-01-01'), '江苏', 2)['reason']);
|
||||
|
||||
// 再导一次内容相同 → 跳过;内容不同且不加 --force → 整批报错不写
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(0);
|
||||
$changed = $this->rulesFile([$this->waybill('QB-102', '常州圆通其他', 9.9)]);
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id])->assertExitCode(1);
|
||||
$this->assertEqualsWithDelta(3.6, $new->fresh()->billing_rows[0]['base'], 0.0001);
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id, '--force' => true])->assertExitCode(0);
|
||||
$this->assertEqualsWithDelta(9.9, $new->fresh()->billing_rows[0]['base'], 0.0001);
|
||||
}
|
||||
|
||||
public function testImportRejectsWholeBatchWhenCompanyNameDoesNotMatch(): void
|
||||
{
|
||||
$a = $this->version('QB-201', '沭阳德邦', 'SYDB', '2026-02-01', null);
|
||||
$this->version('QB-202', '沭阳德邦其他', 'SYDBQT', '2026-02-01', null);
|
||||
$file = $this->rulesFile([$this->waybill('QB-201', '沭阳德邦', 6.8), $this->waybill('QB-202', '别的快递', 6.8)]);
|
||||
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(1);
|
||||
$this->assertNull($a->fresh()->billing_structure);
|
||||
}
|
||||
|
||||
public function testQuoteDetailShowsBillingSummary(): void
|
||||
{
|
||||
$version = $this->version('QB-301', '彩艺德邦', 'CYDB', '2024-07-01', null);
|
||||
$version->forceFill([
|
||||
'billing_structure' => ['mode' => 'first_additional', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => 5.0, 'rate_tiers' => [],
|
||||
'rounding' => 'round', 'volumetric_divisor' => 10000, 'weight_limit' => 60.0, 'per_ticket_surcharge' => 1.0],
|
||||
'billing_rows' => [['region' => '江苏', 'flat' => [], 'base' => 13.0, 'rates' => [1.6]]],
|
||||
])->save();
|
||||
|
||||
$quotes = (new QuoteManagementSnapshot('/nonexistent-billing.json'))->load()['quotes'];
|
||||
$quote = collect($quotes)->firstWhere('id', 'QB-301');
|
||||
|
||||
$this->assertStringContainsString('首重5kg内按首重价', $quote['billing_summary']);
|
||||
$this->assertStringContainsString('每票加收 1 元', $quote['billing_summary']);
|
||||
$this->assertStringContainsString('1 个地区', $quote['billing_summary']);
|
||||
$this->assertCalculatorAddsPerTicket();
|
||||
}
|
||||
|
||||
private function assertCalculatorAddsPerTicket(): void
|
||||
{
|
||||
$result = (new CourierQuoteFreightCalculator())->calculate('CYDB', Carbon::parse('2026-01-01'), '江苏', 5.5);
|
||||
$this->assertSame(6.0, $result['billable_weight']);
|
||||
$this->assertEqualsWithDelta(15.6, $result['amount'], 0.001);
|
||||
$this->assertSame('13+(6-5)×1.6+每票1', $result['formula']);
|
||||
}
|
||||
|
||||
public function test_import_rejects_duplicate_versions_negative_rates_and_unknown_segments_without_partial_writes(): void
|
||||
{
|
||||
$version = $this->version('QB-SAFE', '安全报价', 'SAFE', '2026-01-01', null);
|
||||
$valid = $this->waybill('QB-SAFE', '安全报价', 2);
|
||||
$negative = $valid;
|
||||
$negative['rows'][0]['rates'][0] = -1;
|
||||
$unknown = $valid;
|
||||
$unknown['conversion'] = 'needs_segment_mode';
|
||||
$unknown['segments_rows'] = [['region' => '江苏', 'segments' => [['lo' => 0, 'hi' => null, 'type' => 'unknown', 'base' => 2, 'rate' => 1]]]];
|
||||
foreach ([[$valid, $valid], [$negative], [$unknown], ['invalid-row']] as $items) {
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $this->rulesFile($items), '--company' => $this->company->id])->assertExitCode(1);
|
||||
$this->assertNull($version->fresh()->billing_structure);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_city_specific_prices_require_the_matching_province_and_accept_chinese_parentheses(): void
|
||||
{
|
||||
$version = $this->version('QB-CITY', '城市报价', 'CITY', '2026-01-01', null);
|
||||
$version->forceFill([
|
||||
'billing_structure' => ['mode' => 'waybill_rate', 'rate_tiers' => []],
|
||||
'billing_rows' => [
|
||||
['region' => '深圳(广东)', 'base' => 5, 'rates' => [1]],
|
||||
['region' => '江苏', 'base' => 10, 'rates' => [2]],
|
||||
],
|
||||
])->save();
|
||||
$calculator = new CourierQuoteFreightCalculator();
|
||||
$this->assertEquals(7, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '广东省', 2, city: '深圳市')['amount']);
|
||||
$this->assertEquals(14, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '江苏省', 2, city: '深圳市')['amount']);
|
||||
}
|
||||
|
||||
private function waybill(string $code, string $name, float $base): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ([['江苏', 0.48, 0.98, 1.28], ['广东', 0.75, 3, 3.3], ['深圳(广东)', 1, 3, 3.3]] as [$region, $r1, $r2, $r3]) {
|
||||
$rows[] = ['region' => $region, 'flat' => [], 'base' => $base, 'rates' => [$r1, $r2, $r3], 'source_row' => 2];
|
||||
}
|
||||
|
||||
return [
|
||||
'version_id' => $code, 'company_name' => $name, 'conversion' => 'direct',
|
||||
'structure' => ['mode' => 'waybill_rate', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [35.0, 50.0], 'rounding' => 'none'],
|
||||
'rows' => $rows,
|
||||
'params' => ['volumetric_divisor' => 6000, 'weight_limit_kg' => null, 'surcharges' => [], 'notes' => ['面单预充6元/单']],
|
||||
];
|
||||
}
|
||||
|
||||
private function rulesFile(array $versions): string
|
||||
{
|
||||
$path = storage_path('framework/testing/billing-rules-' . uniqid('', true) . '.json');
|
||||
@mkdir(dirname($path), 0777, true);
|
||||
file_put_contents($path, json_encode(['versions' => $versions], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
$this->files[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function version(string $code, string $name, string $expcode, string $from, ?string $to): CourierQuoteVersion
|
||||
{
|
||||
$change = CourierQuoteChange::create([
|
||||
'company_id' => $this->company->id,
|
||||
'number' => 'BG-T-' . $code,
|
||||
'source_quote_id' => $code,
|
||||
'source_sheet_name' => $name,
|
||||
'carrier_name' => $name,
|
||||
'quote_type' => '标准快递报价',
|
||||
'change_type' => '面单调整',
|
||||
'status' => CourierQuoteChange::STATUS_APPROVED,
|
||||
'effective_date' => $from,
|
||||
'is_retrospective' => false,
|
||||
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
||||
'summary' => '测试报价',
|
||||
'created_by' => $this->user->id,
|
||||
'submitted_by' => $this->user->id,
|
||||
'submitted_at' => now(),
|
||||
'approved_by' => $this->user->id,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
return CourierQuoteVersion::create([
|
||||
'company_id' => $this->company->id,
|
||||
'version_code' => $code,
|
||||
'source_quote_id' => $code,
|
||||
'source_change_id' => $change->id,
|
||||
'sheet_name' => $name,
|
||||
'company_name' => $name,
|
||||
'carrier_name' => $name,
|
||||
'courier_expcode' => $expcode,
|
||||
'quote_type' => '标准快递报价',
|
||||
'effective_date' => $from,
|
||||
'end_date' => $to,
|
||||
'source_range' => 'A1:C2',
|
||||
'values_json' => json_encode([['省份', '面单', '费率']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'formulas_json' => json_encode([[]], JSON_THROW_ON_ERROR),
|
||||
'status' => 'approved',
|
||||
'signature_status' => '历史签署资料已归档',
|
||||
'created_by' => $this->user->id,
|
||||
'approved_by' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -107,17 +107,6 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
|
||||
$this->assertStringContainsString("GD-EXEMPT,'3316392626048007769,拍A发B", $csv);
|
||||
$this->assertStringNotContainsString('GD565967381', $csv);
|
||||
|
||||
// 发运营:按种菜报销字段导出,忽略损耗页的列设置。
|
||||
$operations = $this->loginAs()->get(route('business.planting-data.planting-loss.export', [
|
||||
'company_id' => $this->company->id, 'month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement',
|
||||
]));
|
||||
$operations->assertOk();
|
||||
$opsCsv = $operations->streamedContent();
|
||||
$this->assertStringContainsString(implode(',', array_values(PlantingReimbursementPageData::COLUMNS)), $opsCsv);
|
||||
$this->assertStringContainsString('GD-EXEMPT', $opsCsv);
|
||||
$this->assertStringNotContainsString('GD565967381', $opsCsv);
|
||||
$this->assertStringNotContainsString('损耗判定', $opsCsv);
|
||||
|
||||
$reimbursements = $this->loginAs()->get(route('business.planting-data.planting-reimbursements.export', [
|
||||
'company_id' => $this->company->id, 'month' => '2026-09',
|
||||
]));
|
||||
|
||||
@ -105,10 +105,6 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'operation' => '商品与库存管理导入',
|
||||
]],
|
||||
'jushuitan_cost_price_history_count' => 1,
|
||||
'jushuitan_cost_price_observations' => [[
|
||||
'record_type_label' => '观测到成本价变化', 'before_price' => 499, 'after_price' => 500,
|
||||
'effect_start_time' => '2026-10-20 12:00:00', 'remark' => '发现时间,不是真实改价时间',
|
||||
]],
|
||||
'purchase_order_unit_price' => 65,
|
||||
'purchase_order_finished_at' => '2026-08-20 11:03:05',
|
||||
'purchase_order_id' => 827380,
|
||||
@ -147,7 +143,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'last_succeeded_at' => '2026-09-01 03:02:00',
|
||||
],
|
||||
'jushuitan_cost_price_history_sync_status' => [
|
||||
'code' => 'failed',
|
||||
'code' => 'ready',
|
||||
'initialized' => true,
|
||||
'last_succeeded_at' => '2026-09-15 00:05:00',
|
||||
],
|
||||
@ -170,8 +166,6 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
->assertOk()
|
||||
->assertSee('data-product-catalog-page', false)
|
||||
->assertSeeText('普通商品资料')
|
||||
->assertSeeText('聚水潭成本价历史最近同步失败')
|
||||
->assertSeeText('观测 1 条(最近20条)')
|
||||
->assertSee('data-sync-state="healthy"', false)
|
||||
->assertSeeText('同步正常')
|
||||
->assertSeeText('最近同步 2026-08-15 17:51:03')
|
||||
|
||||
@ -8,7 +8,6 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class SupplierPayableFinalPagesTest extends FeatureTestCase
|
||||
@ -204,97 +203,6 @@ class SupplierPayableFinalPagesTest extends FeatureTestCase
|
||||
$this->assertStringNotContainsString('快递名称', $export->streamedContent());
|
||||
}
|
||||
|
||||
public function test_final_return_reconciliation_page_paginates_fifteen_rows(): void
|
||||
{
|
||||
$records = [];
|
||||
for ($index = 1; $index <= 20; $index++) {
|
||||
$records[sprintf('return:%02d', $index)] = [
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-30',
|
||||
'supplier' => '退货供应商',
|
||||
'barcode' => sprintf('RC-%02d', $index),
|
||||
'sku_name' => '退货商品',
|
||||
'qty' => '1',
|
||||
'unit_price' => '10',
|
||||
'amount' => '10',
|
||||
];
|
||||
}
|
||||
app(SupplierPayableOverrideStore::class)->saveBatch(
|
||||
'final:supplier:return-reconciliation',
|
||||
$records,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
|
||||
$baseUrl = route('business.pages.show', [
|
||||
'company_id' => (int) $this->company->id,
|
||||
'section' => 'supplier-payables-final',
|
||||
'page' => 'return-reconciliation',
|
||||
]);
|
||||
$url = fn (int $page): string => $baseUrl . '?page=' . $page;
|
||||
|
||||
$this->loginAs()->get($url(1))
|
||||
->assertOk()
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('第 1 页 / 共 2 页')
|
||||
->assertSeeText('RC-01')
|
||||
->assertDontSeeText('RC-16');
|
||||
|
||||
$this->loginAs()->get($url(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 页 / 共 2 页')
|
||||
->assertSeeText('RC-16')
|
||||
->assertSeeText('RC-20')
|
||||
->assertDontSeeText('RC-01');
|
||||
}
|
||||
|
||||
public function test_final_payment_reconciliation_page_paginates_fifteen_rows(): void
|
||||
{
|
||||
$records = [];
|
||||
for ($index = 1; $index <= 20; $index++) {
|
||||
$records[sprintf('payment:%02d', $index)] = [
|
||||
'summary_month' => '2026-09',
|
||||
'stockNo' => sprintf('RK-%02d', $index),
|
||||
'purchaseNo' => sprintf('CG-%02d', $index),
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '货款供应商',
|
||||
'skuCode' => sprintf('PM-%02d', $index),
|
||||
'productCode' => sprintf('P-%02d', $index),
|
||||
'skuName' => '货款商品',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
];
|
||||
}
|
||||
app(SupplierPayableOverrideStore::class)->saveBatch(
|
||||
'final:supplier:payment-reconciliation',
|
||||
$records,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
|
||||
$baseUrl = route('business.pages.show', [
|
||||
'company_id' => (int) $this->company->id,
|
||||
'section' => 'supplier-payables-final',
|
||||
'page' => 'payment-reconciliation',
|
||||
]);
|
||||
$url = fn (int $page): string => $baseUrl . '?page=' . $page;
|
||||
|
||||
$this->loginAs()->get($url(1))
|
||||
->assertOk()
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('第 1 页 / 共 2 页')
|
||||
->assertSeeText('PM-01')
|
||||
->assertDontSeeText('PM-16');
|
||||
|
||||
$this->loginAs()->get($url(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 页 / 共 2 页')
|
||||
->assertSeeText('PM-16')
|
||||
->assertSeeText('PM-20')
|
||||
->assertDontSeeText('PM-01');
|
||||
}
|
||||
|
||||
public function test_final_import_reads_files_larger_than_one_chunk(): void
|
||||
{
|
||||
$lines = ['月份,入库单号,采购单号,日期,供应商,货号,商品编码,规格名称,数量,单价,金额,预估单价,预估金额,供应商商品编码,备注,仓库名称,快递名称,快递单号,未发出,对账人'];
|
||||
|
||||
@ -105,9 +105,6 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
&& str_contains($text, 'B-1')
|
||||
&& str_contains($text, 'C-1')
|
||||
&& str_contains($text, 'D-1')
|
||||
&& str_contains($text, '采购单号:编号-B-1')
|
||||
&& str_contains($text, '日期:2026-09-30')
|
||||
&& str_contains($text, '厂家数量少 1')
|
||||
&& ! str_contains($text, '`A-1`')
|
||||
&& ! str_contains($text, '鸣飞家具');
|
||||
});
|
||||
@ -129,10 +126,9 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'row',
|
||||
'purchase_no' => '编号-B-1',
|
||||
'date' => '2026-09-30',
|
||||
'barcode' => 'B-1',
|
||||
'difference_type' => '数量/金额不一致',
|
||||
'difference_type' => '金额不一致',
|
||||
'unit_price' => '50',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。');
|
||||
@ -159,8 +155,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'selected',
|
||||
'selected_rows' => [
|
||||
json_encode(['purchaseNo' => '编号-B-1', 'date' => '2026-09-30', 'barcode' => 'B-1', 'type' => '数量/金额不一致']),
|
||||
json_encode(['purchaseNo' => '编号-D-1', 'date' => '2026-09-30', 'barcode' => 'D-1', 'type' => '厂家多']),
|
||||
json_encode(['barcode' => 'B-1', 'type' => '金额不一致', 'unitPrice' => 50]),
|
||||
json_encode(['barcode' => 'D-1', 'type' => '厂家多', 'unitPrice' => 80]),
|
||||
],
|
||||
])
|
||||
->assertStatus(302)
|
||||
@ -202,20 +198,14 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
$rows[] = [
|
||||
'barcode' => sprintf('BULK-%02d', $index),
|
||||
'supplierBarcode' => '',
|
||||
'purchaseNo' => sprintf('PO-BULK-%02d', $index),
|
||||
'date' => '2026-09-30',
|
||||
'unitPrice' => 10,
|
||||
'mineUnitPrice' => 10,
|
||||
'theirsUnitPrice' => 10,
|
||||
'mineQty' => 2,
|
||||
'theirsQty' => 1,
|
||||
'mineAmount' => 20,
|
||||
'theirsAmount' => 10,
|
||||
'qtyDifference' => -1,
|
||||
'amountDifference' => -10,
|
||||
'type' => '数量/金额不一致',
|
||||
'type' => '金额不一致',
|
||||
'settleAmount' => 10,
|
||||
'note' => '厂家数量少 1;按金额低的一方结算',
|
||||
'note' => '金额/数量不一致:按金额低的一方结算',
|
||||
];
|
||||
}
|
||||
|
||||
@ -240,8 +230,6 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
] as $key => $row) {
|
||||
$store->save('final:supplier:payment-reconciliation', $key, $row + [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => '编号-' . $row['skuCode'],
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
|
||||
@ -46,8 +46,6 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('厂家端数据')
|
||||
->assertSeeText('厂家多')
|
||||
->assertSeeText('取金额低的一方结算')
|
||||
->assertSeeText('采购单号 + 日期 + 商品编码')
|
||||
->assertSee('list="finance-supplier-options"', false)
|
||||
->assertSeeText('对账人')
|
||||
->assertSeeText('下载对账单模板');
|
||||
}
|
||||
@ -57,13 +55,11 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30',
|
||||
'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
|
||||
'summary_month' => '2026-09', 'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
|
||||
'qty' => '1', 'unitPrice' => '96', 'amount' => '96', 'reconciler' => '李四',
|
||||
], $companyId, null);
|
||||
|
||||
@ -85,17 +81,15 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||||
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$this->seedStatement('晨丰', [
|
||||
$this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.00'),
|
||||
$this->statementRow('ZSJ-1N1097V01', '128.00', '2', '256.00'),
|
||||
$this->statementRow('ZSJ-3N1097V01', '80.00', '1', '80.00'),
|
||||
]);
|
||||
|
||||
@ -109,161 +103,11 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('比对明细')
|
||||
->assertSeeText('返回供应商列表')
|
||||
->assertSeeText('结算金额')
|
||||
->assertSeeText('采购单号')
|
||||
->assertSeeText('日期')
|
||||
->assertSeeText('数量差异(厂家-我方)')
|
||||
->assertSeeText('厂家多 1.00')
|
||||
->assertSeeText('厂家多 ¥128.00')
|
||||
->assertSeeText('编号-ZSJ-1N1097V01')
|
||||
->assertSeeText('导出比对汇总')
|
||||
->assertSee('sr-comparison-scroll', false)
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('ZSJ-3')
|
||||
->assertSeeText('ZSJ-2')
|
||||
->assertDontSeeText('各厂家上传情况');
|
||||
}
|
||||
|
||||
public function test_finance_supplier_filter_requires_an_exact_searchable_name(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
foreach (['晨丰', '鸣飞家具'] as $supplier) {
|
||||
$store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => 'PO-' . $supplier,
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => $supplier,
|
||||
'skuCode' => 'A-1',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
}
|
||||
|
||||
$pageUrl = route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('晨丰'))
|
||||
->assertOk()
|
||||
->assertSeeText('晨丰 · 比对明细')
|
||||
->assertSee('value="晨丰"', false);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
|
||||
->assertOk()
|
||||
->assertSeeText('未找到名为「不存在的厂家」的供应商')
|
||||
->assertDontSeeText('晨丰 · 比对明细')
|
||||
->assertDontSeeText('鸣飞家具 · 比对明细');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_rows_are_paginated_fifteen_per_page(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$statementRows = [];
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$purchaseNo = sprintf('PO-%02d', $i);
|
||||
$barcode = sprintf('A-%02d', $i);
|
||||
$store->save('final:supplier:payment-reconciliation', 'compare:' . $i, [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
'skuCode' => $barcode,
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$statementRows[] = array_merge(
|
||||
$this->statementRow($barcode . 'N1097V01', '10.00', '1', '10.00'),
|
||||
['purchaseNo' => $purchaseNo],
|
||||
);
|
||||
}
|
||||
$this->seedStatement('晨丰', $statementRows);
|
||||
|
||||
$pageUrl = fn (int $page): string => route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '晨丰',
|
||||
'compare_page' => $page,
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl(1))
|
||||
->assertOk()
|
||||
->assertSeeText('第 1 / 2 页')
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('A-01')
|
||||
->assertSeeText('A-15')
|
||||
->assertDontSeeText('A-16')
|
||||
->assertDontSeeText('A-20');
|
||||
|
||||
$this->loginAs()->get($pageUrl(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 / 2 页')
|
||||
->assertSeeText('A-16')
|
||||
->assertSeeText('A-20')
|
||||
->assertDontSeeText('A-15')
|
||||
->assertDontSeeText('A-01');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
foreach ([
|
||||
'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'],
|
||||
'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'],
|
||||
'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'],
|
||||
] as $key => $row) {
|
||||
$store->save('final:supplier:payment-reconciliation', $key, $row + [
|
||||
'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09',
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
], $companyId, null);
|
||||
}
|
||||
$this->seedStatement('晨丰', [
|
||||
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']),
|
||||
array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']),
|
||||
array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']),
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
]));
|
||||
$content = $response->streamedContent();
|
||||
|
||||
$response->assertOk()
|
||||
->assertDownload()
|
||||
->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
|
||||
foreach ([
|
||||
'供应商,晨丰',
|
||||
'月份,2026-09',
|
||||
'对账人,张三',
|
||||
'双方一致,1',
|
||||
'厂家多,1',
|
||||
'数量/金额不一致,1',
|
||||
'厂家少,0',
|
||||
'采购单号,日期,我方商品编码,厂家条码,比对结果',
|
||||
'PO-1',
|
||||
'PO-2',
|
||||
'PO-D',
|
||||
'厂家多 1',
|
||||
'厂家多 ¥50.00',
|
||||
] as $expected) {
|
||||
$this->assertStringContainsString($expected, $content);
|
||||
}
|
||||
|
||||
$this->assertStringNotContainsString('PO-OTHER', $content);
|
||||
}
|
||||
|
||||
public function test_supplier_page_shows_uploads_and_return_rows(): void
|
||||
{
|
||||
$this->seedStatement('晨丰', [
|
||||
@ -288,7 +132,7 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('厂家对账单 · 上传与实时数据')
|
||||
->assertSeeText('上传记录')
|
||||
->assertSeeText('退货扣减确认')
|
||||
->assertSeeText('退货金额')
|
||||
->assertSeeText('合计扣减')
|
||||
->assertSeeText('全部确认无异议')
|
||||
->assertSeeText('待确认')
|
||||
->assertSeeText('条形码')
|
||||
@ -298,15 +142,7 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('查询')
|
||||
->assertSeeText('导出当前筛选结果');
|
||||
|
||||
$response->assertSee('sr-return-scroll', false);
|
||||
|
||||
$html = (string) $response->getContent();
|
||||
$uploadHistoryPosition = strpos($html, ' · 上传记录');
|
||||
$returnPanelPosition = strpos($html, '退货扣减确认');
|
||||
$this->assertNotFalse($uploadHistoryPosition, '内部页面应保留上传记录。');
|
||||
$this->assertNotFalse($returnPanelPosition, '内部页面应正常渲染退货面板。');
|
||||
$this->assertGreaterThan($returnPanelPosition, $uploadHistoryPosition, '上传记录应移动到页面最下面。');
|
||||
|
||||
$tableStart = strpos($html, '<table class="sr-return-table">');
|
||||
$tableEnd = $tableStart === false ? false : strpos($html, '</table>', $tableStart);
|
||||
$this->assertNotFalse($tableStart, '厂家退货表应正常渲染。');
|
||||
@ -319,48 +155,10 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_supplier_page_uses_a_searchable_exact_supplier_picker(): void
|
||||
{
|
||||
$this->seedStatement('晨丰', [
|
||||
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
|
||||
]);
|
||||
$this->seedStatement('鸣飞家具', [
|
||||
array_merge($this->statementRow('SJ-1', '96.00', '1', '96.00'), ['supplier' => '鸣飞家具']),
|
||||
]);
|
||||
|
||||
$pageUrl = route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl)
|
||||
->assertOk()
|
||||
->assertSee('name="supplier"', false)
|
||||
->assertSee('list="supplier-name-options"', false)
|
||||
->assertSeeText('查询厂家')
|
||||
->assertSee('value="晨丰"', false)
|
||||
->assertSee('value="鸣飞家具"', false);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('鸣飞家具'))
|
||||
->assertOk()
|
||||
->assertSeeText('鸣飞家具 · 上传记录')
|
||||
->assertDontSeeText('晨丰 · 上传记录');
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
|
||||
->assertOk()
|
||||
->assertSeeText('未找到名为「不存在的厂家」的厂家')
|
||||
->assertDontSeeText('晨丰 · 上传记录')
|
||||
->assertDontSeeText('鸣飞家具 · 上传记录');
|
||||
}
|
||||
|
||||
public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$this->seedStatement('鸣飞家具', [
|
||||
array_merge($this->statementRow('OTHER-SUPPLIER', '100.00', '1', '100.00'), ['supplier' => '鸣飞家具']),
|
||||
]);
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$store->save('final:supplier:return-reconciliation', sprintf('portal:%02d', $i), [
|
||||
'summary_month' => '2026-09',
|
||||
@ -400,12 +198,6 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('PORTAL-01')
|
||||
->assertDontSeeText('PORTAL-20')
|
||||
->assertDontSeeText('OTHER-SUPPLIER-RETURN')
|
||||
->assertDontSeeText('鸣飞家具')
|
||||
->assertDontSeeText('最近上传')
|
||||
->assertDontSee('supplier-name-options', false)
|
||||
->assertSee('sp-return-scroll', false)
|
||||
->assertSee('id="portal-return-date-from"', false)
|
||||
->assertSee('data-return-date-range="last-month"', false)
|
||||
->assertDontSeeText('预估单价');
|
||||
|
||||
$this->loginAs($vendor)->get($portalUrl . '?return_page=2')
|
||||
@ -491,103 +283,6 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
|
||||
}
|
||||
|
||||
public function test_vendor_portal_rejects_a_statement_for_another_supplier(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
$vendor = $this->createVendorPortalUser('晨丰');
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'statement.csv',
|
||||
"\xEF\xBB\xBF" . self::HEADER . "\n"
|
||||
. ",,PORTAL-WRONG-SUPPLIER,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00\n",
|
||||
);
|
||||
|
||||
$this->loginAs($vendor)
|
||||
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
|
||||
'import' => $file,
|
||||
])
|
||||
->assertSessionHasErrors([
|
||||
'import' => '第 2 行:供应商必须填写「晨丰」,不能提交其他厂家的数据。',
|
||||
]);
|
||||
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id));
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
|
||||
}
|
||||
|
||||
public function test_return_bookkeeping_note_is_identical_on_finance_and_supplier_views(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:payment-reconciliation',
|
||||
'mine:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => 'PO-LOGISTICS-1',
|
||||
'date' => '2026-09-27',
|
||||
'supplier' => '曹县宇诺',
|
||||
'reconciler' => '陈亲园',
|
||||
'skuCode' => 'CZ008407',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '259',
|
||||
'amount' => '259',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:return-reconciliation',
|
||||
'return:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-27',
|
||||
'supplier' => '曹县宇诺',
|
||||
'barcode' => 'CZ008407N1178V01',
|
||||
'sku_name' => '款式二 160/80/75 黑胡桃色',
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
'express_no' => '202609270079',
|
||||
'reconciler' => '陈亲园',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
|
||||
$finance = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '曹县宇诺',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '陈亲园',
|
||||
]));
|
||||
$finance->assertOk()
|
||||
->assertSeeText('退货扣减 · 财务与厂家同步')
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('常州松宇货运')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流')
|
||||
->assertSeeText('259.00');
|
||||
|
||||
$supplier = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
'supplier' => '曹县宇诺',
|
||||
]));
|
||||
$supplier->assertOk()
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('不扣本厂家,转常州松宇货运记账')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
|
||||
|
||||
$vendor = $this->createVendorPortalUser('曹县宇诺');
|
||||
$this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
|
||||
'company_id' => $companyId,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('不扣本厂家,转常州松宇货运记账')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
|
||||
}
|
||||
|
||||
public function test_statement_rows_are_paginated_fifteen_per_page(): void
|
||||
{
|
||||
$rows = [];
|
||||
@ -637,19 +332,15 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
|
||||
$this->loginAs()->get($pageUrl(1))
|
||||
->assertOk()
|
||||
->assertSeeText('每页 10 行')
|
||||
->assertSeeText('每页 15 行')
|
||||
->assertSeeText('第 1 / 2 页')
|
||||
->assertSeeText('RT-01')
|
||||
->assertSeeText('RT-10')
|
||||
->assertDontSeeText('RT-11')
|
||||
->assertDontSeeText('RT-20');
|
||||
|
||||
$this->loginAs()->get($pageUrl(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 / 2 页')
|
||||
->assertSeeText('RT-11')
|
||||
->assertSeeText('RT-20')
|
||||
->assertDontSeeText('RT-10')
|
||||
->assertDontSeeText('RT-01');
|
||||
}
|
||||
|
||||
@ -699,14 +390,11 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', $filters))
|
||||
->assertOk()
|
||||
->assertSeeText('当前筛选 19 件,退货金额 ¥1,900.00')
|
||||
->assertSee('value="2026-09-20"', false)
|
||||
->assertSee('value="2026-09-21"', false)
|
||||
->assertSee('data-return-date-range="last-month"', false)
|
||||
->assertSeeText('筛选出 19 行,全部 21 行')
|
||||
->assertSeeText('MATCH-01')
|
||||
->assertDontSeeText('MATCH-20')
|
||||
->assertDontSeeText('OTHER-01')
|
||||
->assertSeeText('导出当前筛选结果');
|
||||
->assertSeeText('导出当前筛选结果(19 行)');
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', $filters + ['return_page' => 2]))
|
||||
->assertOk()
|
||||
@ -716,66 +404,6 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSee('return_status=pending', false);
|
||||
}
|
||||
|
||||
public function test_return_summary_uses_quantity_and_amount_without_row_counts(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$rows = [
|
||||
'summary:1' => ['qty' => '2', 'amount' => '200'],
|
||||
'summary:2' => ['qty' => '3', 'amount' => '300'],
|
||||
'summary:3' => ['qty' => '1', 'amount' => '50'],
|
||||
'summary:4' => ['qty' => '4', 'amount' => '400'],
|
||||
];
|
||||
foreach ($rows as $key => $values) {
|
||||
$store->save('final:supplier:return-reconciliation', $key, $values + [
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-20',
|
||||
'supplier' => '晨丰',
|
||||
'barcode' => strtoupper($key),
|
||||
'unit_price' => '100',
|
||||
], $companyId, null);
|
||||
}
|
||||
|
||||
$confirmations = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class);
|
||||
$confirmations->mark('晨丰', 'summary:1', 'confirmed', $companyId, null);
|
||||
$confirmations->mark('晨丰', 'summary:3', 'disputed', $companyId, null);
|
||||
|
||||
$vendor = $this->createVendorPortalUser('晨丰');
|
||||
$portal = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
|
||||
'company_id' => $companyId,
|
||||
]));
|
||||
$portal->assertOk()
|
||||
->assertSeeText('退货件数')
|
||||
->assertSeeText('退货金额')
|
||||
->assertSee('10<small>件</small>', false)
|
||||
->assertSeeText('¥950.00')
|
||||
->assertSee('2<small>件</small>', false)
|
||||
->assertSeeText('¥200.00')
|
||||
->assertSee('1<small>件</small>', false)
|
||||
->assertSeeText('¥50.00')
|
||||
->assertSee('7<small>件</small>', false)
|
||||
->assertSeeText('¥700.00')
|
||||
->assertDontSeeText('退货合计')
|
||||
->assertDontSeeText('扣减合计')
|
||||
->assertDontSeeText('已确认 2 行')
|
||||
->assertDontSeeText('异议 1 行')
|
||||
->assertDontSeeText('待确认 3 行');
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
'supplier' => '晨丰',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('退货件数')
|
||||
->assertSeeText('退货金额')
|
||||
->assertSee('10<small>件</small>', false)
|
||||
->assertSeeText('¥950.00')
|
||||
->assertDontSeeText('退货合计')
|
||||
->assertDontSeeText('扣减合计');
|
||||
}
|
||||
|
||||
public function test_confirm_all_only_applies_to_the_current_return_filter(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
@ -892,7 +520,7 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
'supplier' => '晨丰',
|
||||
]))->assertOk();
|
||||
|
||||
$page()->assertSeeText('退货金额')->assertSeeText('¥2,653.00');
|
||||
$page()->assertSeeText('合计扣减 ¥2,653.00');
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.return.confirm'), ['supplier' => '晨丰', 'key' => 'return:1'])
|
||||
@ -993,14 +621,14 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
public function test_matcher_applies_the_agreed_settlement_rules(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'],
|
||||
['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'],
|
||||
['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致
|
||||
['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低)
|
||||
['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||||
['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', 'barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'],
|
||||
['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||||
['barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'], // 厂家多
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
@ -1010,31 +638,25 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame(1, $result['summary']['supplierMissing']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(330.0, $result['summary']['mineAmount']);
|
||||
$this->assertSame(330.0, $result['summary']['theirsAmount']);
|
||||
$this->assertSame(80.0, $result['summary']['supplierOverAmount']);
|
||||
$this->assertSame(250.0, $result['summary']['settleAmount']);
|
||||
$this->assertSame(80.0, $result['summary']['differenceAmount']);
|
||||
|
||||
$byBarcode = collect($result['rows'])->keyBy('barcode');
|
||||
$this->assertSame(50.0, $byBarcode['B-1']['settleAmount']);
|
||||
$this->assertSame(-1.0, $byBarcode['B-1']['qtyDifference']);
|
||||
$this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']);
|
||||
$this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']);
|
||||
$this->assertSame('2026-09-30', $byBarcode['B-1']['date']);
|
||||
$this->assertSame(0.0, $byBarcode['C-1']['settleAmount']);
|
||||
$this->assertSame(0.0, $byBarcode['D-1']['settleAmount']);
|
||||
}
|
||||
|
||||
public function test_matcher_normalizes_supplier_barcode_suffix(): void
|
||||
{
|
||||
// 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。
|
||||
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', 'barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
@ -1047,92 +669,6 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
|
||||
}
|
||||
|
||||
public function test_matcher_uses_product_code_instead_of_sku_code(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => 'PO-CODE-1',
|
||||
'date' => '2026-09-30',
|
||||
'skuCode' => 'SKU-ONLY',
|
||||
'productCode' => 'CJ019312N1178V01',
|
||||
'unitPrice' => '182.5',
|
||||
'qty' => '2',
|
||||
'amount' => '365',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => 'PO-CODE-1',
|
||||
'createdAt' => '2026-09-30',
|
||||
'barcode' => 'CJ019312N1097V01',
|
||||
'unitPrice' => '182.5',
|
||||
'qty' => '2',
|
||||
'amount' => '365',
|
||||
]];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']);
|
||||
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']);
|
||||
}
|
||||
|
||||
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
|
||||
['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
$rows = collect($result['rows'])->keyBy('purchaseNo');
|
||||
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']);
|
||||
$this->assertSame('2026-09-30', $rows['PO-1']['date']);
|
||||
$this->assertSame(1.0, $rows['PO-1']['qtyDifference']);
|
||||
$this->assertSame(100.0, $rows['PO-1']['amountDifference']);
|
||||
$this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']);
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']);
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']);
|
||||
$this->assertSame(3.0, $result['summary']['supplierOverQty']);
|
||||
$this->assertSame(300.0, $result['summary']['supplierOverAmount']);
|
||||
}
|
||||
|
||||
public function test_matcher_can_fallback_only_when_missing_purchase_number_matches_one_row(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
|
||||
$this->assertStringContainsString('按日期+商品编码唯一匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
|
||||
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(0, $result['summary']['match']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(2, $result['summary']['supplierMissing']);
|
||||
$this->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_header_mismatch_message_lists_the_differences(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceObservationRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer as Differ;
|
||||
use Tests\TestCase;
|
||||
|
||||
class JushuitanCostPriceSnapshotDifferTest extends TestCase
|
||||
{
|
||||
private Differ $differ;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
|
||||
'upstream_mirrors.connection' => 'business_mirror', 'cache.default' => 'array']);
|
||||
DB::purge('business_mirror');
|
||||
Carbon::setTestNow('2026-10-20 12:00:00');
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create(Differ::STATES, function (Blueprint $t): void {
|
||||
$t->string('dataset_key')->primary();
|
||||
foreach (['status', 'source_key', 'source_object', 'target_table', 'run_mode', 'generation', 'last_error_code'] as $c) $t->string($c)->nullable();
|
||||
foreach (['cursor', 'high_watermark'] as $c) $t->json($c)->nullable();
|
||||
foreach (['window_started_at', 'window_ends_at', 'initial_completed_at', 'last_attempted_at', 'last_succeeded_at'] as $c) $t->dateTime($c, 6)->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
$schema->create('ops_jst_sku', function (Blueprint $t): void {
|
||||
$t->char('_mirror_key', 64)->primary(); $t->string('sku_type'); $t->string('sku_id');
|
||||
$t->decimal('cost_price', 20, 4)->nullable(); $t->dateTime('modified')->nullable(); $t->dateTime('_mirror_synced_at', 6);
|
||||
});
|
||||
$schema->create(Differ::SNAPSHOTS, function (Blueprint $t): void {
|
||||
$t->char('source_key', 64)->primary(); $t->string('sku_id'); $t->decimal('cost_price', 20, 4); $t->dateTime('captured_at', 6);
|
||||
});
|
||||
$schema->create(Differ::OBSERVATIONS, function (Blueprint $t): void {
|
||||
$t->char('id', 64)->primary(); $t->char('source_key', 64); $t->string('sku_id');
|
||||
$t->decimal('before_price', 20, 4); $t->decimal('after_price', 20, 4);
|
||||
$t->dateTime('previous_observed_at', 6); $t->dateTime('observed_at', 6); $t->dateTime('source_modified_at', 6)->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table(Differ::STATES)->insert([
|
||||
'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, 'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-10-19 00:00:00', 'last_succeeded_at' => '2026-10-20 11:59:00',
|
||||
]);
|
||||
$this->differ = new Differ(new GenericBusinessMirrorRepository(app('db')), new BusinessMirrorDatasetRegistry());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_baseline_is_resumable_and_does_not_invent_history_then_observes_at_capture_time(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$this->product('a', 'SKU-A', 12);
|
||||
$this->product('b', 'SKU-B', 20);
|
||||
$this->assertSame('running', $this->differ->sync(1, 1)['status']);
|
||||
$db->table(Differ::STATES)->where('dataset_key', Differ::DATASET_KEY)->update(['status' => 'failed']);
|
||||
$this->assertSame('succeeded', $this->differ->sync(1, 3)['status']);
|
||||
$this->assertSame(2, $db->table(Differ::SNAPSHOTS)->count());
|
||||
$this->assertSame(0, $db->table(Differ::OBSERVATIONS)->count());
|
||||
|
||||
Carbon::setTestNow('2026-10-20 12:10:00');
|
||||
$db->table('ops_jst_sku')->where('_mirror_key', 'a')->update(['cost_price' => 15.1234, '_mirror_synced_at' => '2026-10-20 12:09:00']);
|
||||
$result = $this->differ->sync(10, 2);
|
||||
$this->assertSame(1, $result['changes']);
|
||||
$event = $db->table(Differ::OBSERVATIONS)->first();
|
||||
$this->assertSame('2026-10-20 12:10:00.000000', $event->observed_at);
|
||||
$this->assertSame('2026-01-01 00:00:00', $event->source_modified_at);
|
||||
$this->assertEquals(12, $event->before_price);
|
||||
$this->assertEquals(15.1234, $event->after_price);
|
||||
$this->assertSame(0, $this->differ->sync(10, 2)['changes']);
|
||||
$this->assertSame(1, $db->table(Differ::OBSERVATIONS)->count());
|
||||
$this->assertFalse(Schema::connection('business_mirror')->hasTable('jushuitan_cost_price_history_rows'));
|
||||
$this->assertFalse(Schema::connection('business_mirror')->hasTable('jushuitan_cost_price_history_sync_states'));
|
||||
|
||||
$rows = [(object) ['_mirror_key' => 'a'], (object) ['_mirror_key' => 'b']];
|
||||
(new JushuitanCostPriceObservationRepository(new GenericBusinessMirrorRepository(app('db'))))->attach($rows);
|
||||
$this->assertCount(1, $rows[0]->jushuitan_cost_price_observations);
|
||||
$this->assertCount(0, $rows[1]->jushuitan_cost_price_observations);
|
||||
$this->assertStringContainsString('不是真实改价时间', $rows[0]->jushuitan_cost_price_observations[0]['remark']);
|
||||
}
|
||||
|
||||
public function test_new_products_null_prices_and_same_sku_on_different_source_rows_do_not_invent_changes(): void
|
||||
{
|
||||
$this->product('a', 'SAME', 12);
|
||||
$this->product('b', 'SAME', 25);
|
||||
$this->product('c', 'NULL', null);
|
||||
$this->differ->sync();
|
||||
Carbon::setTestNow('2026-10-20 12:10:00');
|
||||
$this->product('d', 'NEW', 99, '2026-10-20 12:09:00');
|
||||
$this->assertSame(0, $this->differ->sync()['changes']);
|
||||
$this->assertSame(3, DB::connection('business_mirror')->table(Differ::SNAPSHOTS)->count());
|
||||
DB::connection('business_mirror')->table(Differ::STATES)->where('dataset_key', ProductCatalogMirrorRepository::DATASET_KEY)->update(['status' => 'running']);
|
||||
$this->assertSame('source_not_ready', $this->differ->sync()['status']);
|
||||
}
|
||||
|
||||
private function product(string $key, string $sku, ?float $price, string $at = '2026-10-20 11:50:00'): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
|
||||
'_mirror_key' => $key, 'sku_id' => $sku, 'sku_type' => 'normal', 'cost_price' => $price,
|
||||
'modified' => '2026-01-01 00:00:00', '_mirror_synced_at' => $at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Http\Controllers\Business\Pages;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PerformanceDifferenceRemarkTest extends TestCase
|
||||
{
|
||||
private function remark(array $attributes): ?string
|
||||
{
|
||||
$row = (object) array_merge([
|
||||
'platform' => '拼多多',
|
||||
'match_status' => '已匹配',
|
||||
'income_amount' => 0,
|
||||
'discount_amount' => 0,
|
||||
'offset_amount' => 0,
|
||||
'refund_amount' => 0,
|
||||
'product_amount' => 0,
|
||||
'paid_amount' => 0,
|
||||
'difference' => 0,
|
||||
'planting_amount' => null,
|
||||
'remark' => null,
|
||||
], $attributes);
|
||||
|
||||
return (new ReflectionMethod(Pages::class, 'receiptDifferenceRemark'))->invoke(null, $row);
|
||||
}
|
||||
|
||||
public function test_pinduoduo_full_refund_wins_before_other_difference_rules(): void
|
||||
{
|
||||
$this->assertSame('订单实付含已退款部分', $this->remark([
|
||||
'income_amount' => 7.00,
|
||||
'discount_amount' => 17.00,
|
||||
'refund_amount' => -7.00,
|
||||
'product_amount' => 42.00,
|
||||
'paid_amount' => 6.99,
|
||||
'difference' => 17.01,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_pinduoduo_partial_refund_marks_platform_payment_discount(): void
|
||||
{
|
||||
$this->assertSame('差额=平台支付立减', $this->remark([
|
||||
'income_amount' => 896.00,
|
||||
'refund_amount' => -552.36,
|
||||
'product_amount' => 936.00,
|
||||
'paid_amount' => 894.21,
|
||||
'difference' => 1.79,
|
||||
]));
|
||||
|
||||
$this->assertSame('差额=平台支付立减', $this->remark([
|
||||
'income_amount' => 16.43,
|
||||
'refund_amount' => -12.00,
|
||||
'product_amount' => 21.90,
|
||||
'paid_amount' => 16.42,
|
||||
'difference' => 0.01,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_large_unexplained_pinduoduo_difference_stays_pending(): void
|
||||
{
|
||||
$this->assertSame('差额待核实', $this->remark([
|
||||
'income_amount' => 130.00,
|
||||
'refund_amount' => -50.00,
|
||||
'product_amount' => 150.00,
|
||||
'paid_amount' => 100.00,
|
||||
'difference' => 30.00,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@ -151,8 +151,6 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
$this->assertTrue(Calculator::isFreeGoods('tcxj', '书柜 晒图有奖励(无实物)'));
|
||||
$this->assertTrue(Calculator::isVirtualOrderItem('X00006', ''));
|
||||
$this->assertTrue(Calculator::isVirtualOrderItem('GG003-SZ', '透明挂钩3个'));
|
||||
$this->assertTrue(Calculator::isVirtualOrderItem('GG-005', '挂钩'));
|
||||
$this->assertFalse(Calculator::isVirtualOrderItem('DNZ-01', '儿童学习椅'));
|
||||
$this->assertSame(10050, Calculator::deductionCents('100.5', 1));
|
||||
$this->assertSame(20100, Calculator::deductionCents('100.5', 2));
|
||||
}
|
||||
@ -174,8 +172,6 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
$this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00');
|
||||
$this->workOrder(12, 'GD-MERGED', '7777', '2026-09-22 10:00:00');
|
||||
$this->workOrder(13, 'GD-HOOK', '8888', '2026-09-23 10:00:00');
|
||||
// 未匹配到发货但备注拍A发B:同样列入不扣款(发运营)。
|
||||
$this->workOrder(14, 'GD-PAB-NOSHIP', '9090', '2026-09-24 10:00:00', [], '拍A发B');
|
||||
|
||||
// 一单两个包裹:学习椅 ×2 + 无实物商品(不计货值)。
|
||||
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2);
|
||||
@ -197,7 +193,7 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
|
||||
$rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no');
|
||||
|
||||
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-HOOK', 'GD-MERGED', 'GD-NOCOST', 'GD-PAB-NOSHIP', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
|
||||
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-HOOK', 'GD-MERGED', 'GD-NOCOST', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
|
||||
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']);
|
||||
$this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']);
|
||||
$this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']);
|
||||
@ -214,8 +210,6 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
$this->assertStringContainsString('信誉订单虚拟条码', $rows['GD-MERGED']['loss_basis']);
|
||||
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-HOOK']['loss_result']);
|
||||
$this->assertNull($rows['GD-HOOK']['deduction_amount']);
|
||||
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-PAB-NOSHIP']['loss_result']);
|
||||
$this->assertSame('备注含“拍A发B”(未匹配到发货)', $rows['GD-PAB-NOSHIP']['loss_basis']);
|
||||
|
||||
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
|
||||
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);
|
||||
|
||||
@ -32,7 +32,7 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
|
||||
return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at',
|
||||
'income_amount', 'expense_amount', 'online_real_store', $planting, [], [], $map, '淘宝',
|
||||
'income_amount', 'expense_amount', 'online_real_store', $planting, $map, '淘宝',
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,10 +40,10 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
{
|
||||
$bills = [
|
||||
$this->bill('优惠收入', '260630-10001', 0, 3),
|
||||
$this->bill('订单收入', '260630-10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
|
||||
$this->bill('订单收入', '260630-10001', 25),
|
||||
$this->bill('冲减收入', '260630-10001', 0, 2),
|
||||
$this->bill('退款', '260630-10001', 0, 7),
|
||||
$this->bill('订单收入', '10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
|
||||
$this->bill('订单收入', '10001', 25),
|
||||
$this->bill('冲减收入', '10001', 0, 2),
|
||||
$this->bill('退款', '10001', 0, 7),
|
||||
$this->bill('优惠收入', '20002', 4),
|
||||
$this->bill('退款', '20002', 0, 1),
|
||||
$this->bill('冲减收入', '', 0, 8),
|
||||
@ -56,7 +56,7 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]);
|
||||
$key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$paid = $result['groups'][$key . '|260630-10001'];
|
||||
$paid = $result['groups'][$key . '|10001'];
|
||||
$this->assertSame(2, $paid['payment_count']);
|
||||
$this->assertSame(125.0, $paid['income_amount']);
|
||||
$this->assertSame(-3.0, $paid['discount_amount']);
|
||||
@ -84,55 +84,6 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
|
||||
}
|
||||
|
||||
public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
$this->bill('订单收入', '260627-475634091573139', 13.91),
|
||||
$this->bill('订单收入', '260713-475634091573139', 29.51),
|
||||
]);
|
||||
|
||||
$shop = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$first = $result['groups'][$shop . '|260627-475634091573139'];
|
||||
$second = $result['groups'][$shop . '|260713-475634091573139'];
|
||||
$this->assertSame('475634091573139', $first['order_core_no']);
|
||||
$this->assertSame('475634091573139', $second['order_core_no']);
|
||||
$this->assertSame('260627-475634091573139', $first['order_identity']);
|
||||
$this->assertSame('260713-475634091573139', $second['order_identity']);
|
||||
$this->assertSame(13.91, $first['income_amount']);
|
||||
$this->assertSame(29.51, $second['income_amount']);
|
||||
}
|
||||
|
||||
public function test_order_side_exact_full_number_wins_and_ambiguous_core_fallback_never_sums(): void
|
||||
{
|
||||
$command = new SyncStoreReceiptOrderMatches();
|
||||
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'matchOrderSide');
|
||||
$shop = SyncStoreReceiptOrderMatches::normalizeShop('拼多多-欧意朗家具官方旗舰店');
|
||||
$core = '475634091573139';
|
||||
$first = '260627-475634091573139';
|
||||
$second = '260713-475634091573139';
|
||||
$amounts = [
|
||||
$shop . '|' . $first => 13.91,
|
||||
$shop . '|' . $second => 29.51,
|
||||
];
|
||||
$orderSeen = [
|
||||
$shop . '|' . $first => true,
|
||||
$shop . '|' . $second => true,
|
||||
];
|
||||
$identitiesByCore = [$core => [$shop => [$first => true, $second => true]]];
|
||||
$coresByCore = [$core => [$shop => true]];
|
||||
|
||||
$exact = $method->invoke(
|
||||
$command, $first, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
|
||||
);
|
||||
$this->assertSame(['已匹配', 13.91, ''], $exact);
|
||||
|
||||
$ambiguous = $method->invoke(
|
||||
$command, $core, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
|
||||
);
|
||||
$this->assertSame(['未匹配', null, '订单侧同号多条'], $ambiguous);
|
||||
}
|
||||
|
||||
public function test_empty_order_promotions_remain_separate_even_with_an_empty_order_payment(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
@ -183,7 +134,7 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$result = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), [(object) ['store_name' => '京东-测试店', 'order_no' => '70007',
|
||||
'bill_date' => '2026-08-09', 'income' => 12.5, 'bill_item' => '货款(收)']],
|
||||
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], [], [], $map, '京东',
|
||||
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], $map, '京东',
|
||||
);
|
||||
$group = reset($result['groups']);
|
||||
$this->assertSame(12.5, $result['incomeTotal']);
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SupplierReturnAccountingTest extends TestCase
|
||||
{
|
||||
public function test_logistics_adjustment_keeps_supplier_net_amount_and_tracks_logistics_part(): void
|
||||
{
|
||||
$row = (new SupplierReturnAccounting())->enrich([
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
]);
|
||||
|
||||
$this->assertSame('物流', $row['_accounting_target']);
|
||||
$this->assertSame('转物流记账', $row['_accounting_label']);
|
||||
$this->assertSame('不扣本厂家,转常州松宇货运记账', $row['_accounting_note']);
|
||||
$this->assertSame(1.0, $row['_logistics_qty']);
|
||||
$this->assertSame(259.0, $row['_logistics_amount']);
|
||||
$this->assertSame(1.0, $row['_supplier_qty']);
|
||||
$this->assertSame(259.0, $row['_supplier_amount']);
|
||||
}
|
||||
|
||||
public function test_summary_matches_finance_and_supplier_views(): void
|
||||
{
|
||||
$summary = (new SupplierReturnAccounting())->summarize([
|
||||
[
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
],
|
||||
[
|
||||
'qty' => '3',
|
||||
'unit_price' => '250',
|
||||
'amount' => '750',
|
||||
'remark' => '原数量4件,CZ008412N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
],
|
||||
[
|
||||
'qty' => '2',
|
||||
'unit_price' => '30',
|
||||
'amount' => '60',
|
||||
'remark' => '不记账厂家',
|
||||
'title' => '',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(3, $summary['count']);
|
||||
$this->assertSame(2, $summary['supplier_rows']);
|
||||
$this->assertSame(4.0, $summary['supplier_qty']);
|
||||
$this->assertSame(1009.0, $summary['supplier_amount']);
|
||||
$this->assertSame(2, $summary['logistics_rows']);
|
||||
$this->assertSame(2.0, $summary['logistics_qty']);
|
||||
$this->assertSame(509.0, $summary['logistics_amount']);
|
||||
$this->assertSame(['常州松宇货运'], $summary['logistics_companies']);
|
||||
$this->assertSame(1, $summary['no_bookkeeping_rows']);
|
||||
}
|
||||
|
||||
public function test_full_amount_row_is_reduced_when_quantity_was_not_pre_split(): void
|
||||
{
|
||||
$row = (new SupplierReturnAccounting())->enrich([
|
||||
'qty' => '4',
|
||||
'unit_price' => '250',
|
||||
'amount' => '1000',
|
||||
'remark' => '原数量4件,CZ008412N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
]);
|
||||
|
||||
$this->assertSame(1.0, $row['_logistics_qty']);
|
||||
$this->assertSame(250.0, $row['_logistics_amount']);
|
||||
$this->assertSame(3.0, $row['_supplier_qty']);
|
||||
$this->assertSame(750.0, $row['_supplier_amount']);
|
||||
}
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run in a disposable Linux container, never on a production host.
|
||||
set -eu
|
||||
REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
fixture="$(mktemp -d /tmp/caiwu-release-guard.XXXXXX)"
|
||||
cleanup() {
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
chattr -i "$fixture/legacy-entrypoints/$legacy" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$fixture/bin"
|
||||
cat > "$fixture/bin/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$DOCKER_CALLS"
|
||||
if [ "$1" = inspect ]; then
|
||||
case "$*" in
|
||||
*Config.Image*) printf 'caiwu_akaunting:old\n' ;;
|
||||
*) printf '%s\n' "${FAKE_RUNNING_SHA:-}" ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
exit 91
|
||||
EOF
|
||||
chmod +x "$fixture/bin/docker"
|
||||
PATH="$fixture/bin:$PATH"
|
||||
export PATH
|
||||
old=1111111111111111111111111111111111111111
|
||||
new=2222222222222222222222222222222222222222
|
||||
requested=3333333333333333333333333333333333333333
|
||||
passed=0
|
||||
pass() { passed=$((passed + 1)); printf 'PASS: %s\n' "$*"; }
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
new_runtime() {
|
||||
REMOTE_DIR="$fixture/$1"
|
||||
mkdir -p "$REMOTE_DIR/runtime"
|
||||
: > "$REMOTE_DIR/runtime/.env"
|
||||
: > "$REMOTE_DIR/runtime/config-db.env"
|
||||
printf 'CLICKHOUSE_PASSWORD=fixture\nBUSINESS_CLICKHOUSE_PASSWORD=fixture\n' > "$REMOTE_DIR/runtime/clickhouse.env"
|
||||
printf 'BUSINESS_STORAGE_DRIVER=qiniu\nQINIU_ACCESS_KEY=fixture\nQINIU_SECRET_KEY=fixture\n' > "$REMOTE_DIR/runtime/qiniu-storage.env"
|
||||
printf '%s\n' "$old" > "$REMOTE_DIR/.deployed-sha"
|
||||
printf 'previous runtime configuration\n' > "$REMOTE_DIR/compose.jiangling.yml"
|
||||
DOCKER_CALLS="$REMOTE_DIR/docker-calls"
|
||||
: > "$DOCKER_CALLS"
|
||||
FAKE_RUNNING_SHA="$old"
|
||||
export DOCKER_CALLS FAKE_RUNNING_SHA
|
||||
}
|
||||
|
||||
for activation in activate-release.sh activate-incremental-release.sh; do
|
||||
for scenario in stale missing-protocol marker-image-mismatch unchanged; do
|
||||
new_runtime "$activation-$scenario"
|
||||
: > "$REMOTE_DIR/upload.tar.gz"
|
||||
expected="$old"
|
||||
case "$scenario" in
|
||||
stale) printf '%s\n' "$new" > "$REMOTE_DIR/.deployed-sha"; FAKE_RUNNING_SHA="$new" ;;
|
||||
marker-image-mismatch) FAKE_RUNNING_SHA="$new" ;;
|
||||
esac
|
||||
set +e
|
||||
if [ "$scenario" = missing-protocol ]; then
|
||||
sh "$REPO/deploy/$activation" "$REMOTE_DIR/upload.tar.gz" tag "$REMOTE_DIR" "$requested" 0 2 > "$REMOTE_DIR/output" 2>&1
|
||||
else
|
||||
sh "$REPO/deploy/$activation" "$REMOTE_DIR/upload.tar.gz" tag "$REMOTE_DIR" "$requested" 0 2 "$expected" fixture > "$REMOTE_DIR/output" 2>&1
|
||||
fi
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$scenario" = unchanged ]; then
|
||||
[ "$status" -eq 91 ] || { cat "$REMOTE_DIR/output"; fail 'accepted release did not reach image loading'; }
|
||||
else
|
||||
[ "$status" -ne 0 ] || fail "$activation accepted $scenario"
|
||||
if grep -Ev '^inspect ' "$DOCKER_CALLS" | grep -q .; then
|
||||
fail "$activation mutated Docker before rejecting $scenario"
|
||||
fi
|
||||
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'previous runtime configuration' ] || fail 'rejected release overwrote runtime configuration'
|
||||
fi
|
||||
if [ "$scenario" != missing-protocol ]; then
|
||||
[ -s "$REMOTE_DIR/runtime/private/deployments.tsv" ] || fail 'deployment receipt missing'
|
||||
if [ "$scenario" != unchanged ]; then
|
||||
grep -q 'blocked' "$REMOTE_DIR/runtime/private/deployments.tsv" || fail 'rejection not recorded'
|
||||
fi
|
||||
fi
|
||||
pass "$activation: $scenario"
|
||||
done
|
||||
done
|
||||
|
||||
. "$REPO/deploy/release-guard.sh"
|
||||
new_runtime bootstrap
|
||||
rm "$REMOTE_DIR/.deployed-sha"
|
||||
FAKE_RUNNING_SHA=''
|
||||
EXPECTED_PREVIOUS_SHA=none
|
||||
DEPLOY_SHA="$new"
|
||||
caiwu_verify_release_base
|
||||
pass 'first deployment with no running application'
|
||||
EXPECTED_PREVIOUS_SHA=''
|
||||
if caiwu_verify_release_base 2>/dev/null; then fail 'empty baseline accepted'; fi
|
||||
pass 'empty baseline rejected'
|
||||
|
||||
new_runtime ordered-uploaders
|
||||
EXPECTED_PREVIOUS_SHA="$old"
|
||||
DEPLOY_SHA="$new"
|
||||
# Both uploaders checked the same old revision. The newer one activates first.
|
||||
(
|
||||
exec 9> "$REMOTE_DIR/.deploy.lock"
|
||||
flock -x 9
|
||||
caiwu_verify_release_base
|
||||
printf '%s\n' "$new" > "$REMOTE_DIR/.deployed-sha"
|
||||
)
|
||||
FAKE_RUNNING_SHA="$new"
|
||||
DEPLOY_SHA="$requested"
|
||||
if (
|
||||
exec 9> "$REMOTE_DIR/.deploy.lock"
|
||||
flock -x 9
|
||||
caiwu_verify_release_base
|
||||
) 2>/dev/null; then fail 'delayed uploader overwrote the newer release'; fi
|
||||
[ "$(cat "$REMOTE_DIR/.deployed-sha")" = "$new" ] || fail 'production marker regressed'
|
||||
pass 'delayed uploader is rejected after a newer activation'
|
||||
|
||||
new_runtime staged-config
|
||||
RELEASE_DIR="$fixture/staging"
|
||||
RELEASE_APP_URL=''
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
for file in compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml; do
|
||||
printf 'new staged runtime configuration\n' > "$RELEASE_DIR/$file"
|
||||
done
|
||||
printf '#!/bin/sh\nexit 0\n' > "$RELEASE_DIR/install-release-protection.sh"
|
||||
rm "$RELEASE_DIR/registry-config.yml"
|
||||
if caiwu_install_release_files 2>/dev/null; then fail 'partial upload accepted'; fi
|
||||
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'previous runtime configuration' ] || fail 'partial upload replaced runtime files'
|
||||
pass 'incomplete staging directory leaves runtime files untouched'
|
||||
printf 'complete\n' > "$RELEASE_DIR/registry-config.yml"
|
||||
caiwu_install_release_files
|
||||
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'new staged runtime configuration' ] || fail 'staged configuration not installed'
|
||||
pass 'complete isolated upload installs the intended runtime files'
|
||||
|
||||
new_runtime legacy-entrypoints
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
printf '#!/bin/sh\nexit 0\n' > "$REMOTE_DIR/$legacy"
|
||||
done
|
||||
sh "$REPO/deploy/install-release-protection.sh" "$REMOTE_DIR"
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
if cp "$REPO/deploy/$legacy" "$REMOTE_DIR/$legacy" 2>/dev/null; then fail 'old uploader replaced the protected entry point'; fi
|
||||
if sh "$REMOTE_DIR/$legacy" old-arguments 2>/dev/null; then fail 'old activation path accepted a release'; fi
|
||||
pass "$legacy: legacy uploader and activation both rejected"
|
||||
done
|
||||
sh "$REPO/deploy/install-release-protection.sh" "$REMOTE_DIR"
|
||||
pass 'installing entry point protection again is idempotent'
|
||||
printf 'PASS: %s release protection scenarios\n' "$passed"
|
||||
Loading…
x
Reference in New Issue
Block a user