CaiWuXiTong/modules/EcommerceFinance/Services/SupplierBookkeepingConfigStore.php
limeng 09b4ffcb8a 供应商应付-厂家记账:新增厂家记账配置并按配置判定厂家扣款
- 新增配置表 business_supplier_bookkeeping_configs(+ 操作日志表),迁移时导入财务提供的 209 家存量配置
- 记账方式:正常记账 / 仅代发仓发错货记账 / 不记账,支持生效期;配置页支持新增、编辑、删除恢复、Excel 导入导出
- 扣款判定:厂家记账按供应商配置,仅正常记账扣款;仓库记账仅订单发货仓库为代发仓且二级原因含“仓库发错货”时作为厂家扣款项,其余不扣
- 未配置/生效期外显示待配置,多供应商判定不一致显示待确认,均不计入扣款金额
- 明细页新增厂家扣款判定筛选、扣款工单数与扣款金额指标、导出列

涉及:数据库迁移(新建配置表并写入存量配置)、金额汇总展示;不涉及上游写入和权限模型。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
2026-10-19 10:38:36 +08:00

157 lines
7.2 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
/** 供应商应付「厂家记账」配置:每个供应商一条记账方式,删除可恢复,操作留痕。 */
class SupplierBookkeepingConfigStore
{
public const TABLE = 'business_supplier_bookkeeping_configs';
public const EVENTS = 'business_supplier_bookkeeping_config_events';
private const FIELDS = ['mode', 'effective_from', 'effective_to', 'source_text'];
public function query()
{
return DB::table(self::TABLE);
}
/** @return array<string,array<string,mixed>> 供应商名称 => 生效中的配置(未删除) */
public function activeConfigs(): array
{
$result = [];
foreach ($this->query()->whereNull('deleted_at')->get() as $row) {
$result[trim($row->supplier_name)] = (array) $row;
}
return $result;
}
public function filtered(array $filters)
{
$query = $this->query();
$status = $filters['deleted_status'] ?? 'active';
if ($status === 'deleted') {
$query->whereNotNull('deleted_at');
} elseif ($status !== 'all') {
$query->whereNull('deleted_at');
}
if (($filters['supplier_name'] ?? '') !== '') {
$query->where('supplier_name', 'like', '%' . addcslashes($filters['supplier_name'], '\\%_') . '%');
}
if (($filters['mode'] ?? '') !== '') {
$query->where('mode', $filters['mode']);
}
return $query->orderBy('supplier_name')->orderBy('id');
}
/** @param array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text?:string} $data */
public function save(array $data, string $actor, ?int $id = null): int
{
return DB::transaction(function () use ($data, $actor, $id): int {
$before = $id === null ? null : $this->query()->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($id !== null && $before === null, 404);
$name = trim($data['supplier_name']);
$duplicate = $this->query()->where('supplier_name', $name)->when($id !== null, fn ($q) => $q->where('id', '<>', $id))->exists();
if ($duplicate) {
throw ValidationException::withMessages(['supplier_name' => '该供应商已有配置(含已删除),请编辑或恢复原配置。']);
}
$time = $this->time();
$values = ['supplier_name' => $name, 'updated_at' => $time] + $this->values($data);
if ($id === null) {
$id = $this->query()->insertGetId($values + ['creator' => $actor, 'created_at' => $time]);
} else {
$this->query()->where('id', $id)->update($values);
}
$this->event($id, $actor, $before ? 'update' : 'create', ($before ? '修改' : '新增') . '厂家记账配置:' . $name . ' → ' . Rule::describe($values), ['before' => $before, 'after' => $values]);
return $id;
});
}
public function changeDeleted(int $id, bool $deleted, string $actor): void
{
DB::transaction(function () use ($id, $deleted, $actor): void {
$row = $this->query()->where('id', $id)->lockForUpdate()->first();
abort_if($row === null, 404);
if (($row->deleted_at !== null) === $deleted) {
return;
}
$this->query()->where('id', $id)->update(['deleted_at' => $deleted ? $this->time() : null, 'updated_at' => $this->time()]);
$this->event($id, $actor, $deleted ? 'delete' : 'restore', ($deleted ? '删除' : '恢复') . '厂家记账配置:' . $row->supplier_name, ['supplier_name' => $row->supplier_name, 'mode' => $row->mode]);
});
}
/**
* 整批导入:新供应商新增;已有供应商记账方式或生效期不同则按文件更新(已删除的一并恢复);相同则跳过。
*
* @param list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> $rows
* @return array{added:int,updated:int,skipped:int}
*/
public function import(array $rows, string $actor, string $sourceHash): array
{
return DB::transaction(function () use ($rows, $actor, $sourceHash): array {
$added = $updated = $skipped = 0;
$time = $this->time();
foreach ($rows as $row) {
$name = trim($row['supplier_name']);
$values = $this->values($row);
$existing = $this->query()->where('supplier_name', $name)->lockForUpdate()->first();
if ($existing === null) {
$this->query()->insert(['supplier_name' => $name, 'creator' => $actor, 'created_at' => $time, 'updated_at' => $time] + $values);
$added++;
continue;
}
$same = $existing->deleted_at === null;
foreach (['mode', 'effective_from', 'effective_to'] as $field) {
$same = $same && (string) $existing->{$field} === (string) $values[$field];
}
if ($same) {
$skipped++;
continue;
}
$this->query()->where('id', $existing->id)->update($values + ['deleted_at' => null, 'updated_at' => $time]);
$updated++;
}
$this->event(null, $actor, 'import', "导入厂家记账配置:新增{$added}条,更新{$updated}条,未变化{$skipped}条", compact('added', 'updated', 'skipped') + ['source_sha256' => $sourceHash]);
return compact('added', 'updated', 'skipped');
});
}
public function events()
{
return DB::table(self::EVENTS)->orderByDesc('id');
}
private function values(array $data): array
{
$values = [];
foreach (self::FIELDS as $field) {
$value = $data[$field] ?? null;
$values[$field] = $field === 'source_text' ? mb_substr(trim((string) $value), 0, 255) : (($value === '' || $value === null) ? null : (string) $value);
}
abort_unless(isset(Rule::MODES[$values['mode']]), 422, '记账方式无效。');
if ($values['effective_from'] !== null && $values['effective_to'] !== null && $values['effective_from'] > $values['effective_to']) {
throw ValidationException::withMessages(['effective_to' => '生效结束日期不能早于开始日期。']);
}
return $values;
}
private function event(?int $id, string $actor, string $action, string $description, array $details): void
{
DB::table(self::EVENTS)->insert(['config_id' => $id, 'actor' => $actor, 'action' => $action, 'description' => mb_substr($description, 0, 1000), 'details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $this->time()]);
}
private function time(): string
{
return Carbon::now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
}
}