diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 74f06f73..a3ce2bbb 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -9,11 +9,21 @@ use App\Utilities\SidebarPermissions; use Illuminate\Routing\Controller; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +use Throwable; class Pages extends Controller { + private const EDGE_BANDING_SOURCE = 'jiansun'; + + public function __construct( + private readonly EdgeBandingRepository $edgeBanding, + private readonly UpstreamQueryCacheFactory $upstreamCaches, + ) { + } /** * 支持的营销账户平台列表 @@ -92,6 +102,7 @@ class Pages extends Controller 'tabs' => $tabs, 'activeTab' => null, 'content' => $content, + 'edgeBanding' => $this->edgeBandingContent($page), ]); } @@ -111,6 +122,7 @@ class Pages extends Controller 'tabs' => $tabs, 'activeTab' => $activeTab, 'content' => $content, + 'edgeBanding' => $this->edgeBandingContent($page), ]); } @@ -126,6 +138,103 @@ class Pages extends Controller }; } + /** + * 封边条页面只读取上游快照;未启用或无快照时将错误交给页面显示, + * 避免工作台因上游暂不可用直接返回 500。 + */ + protected function edgeBandingContent(string $page): ?array + { + $scope = match ($page) { + 'edge-banding-prices' => 'edge-banding.prices', + 'details' => 'edge-banding.details', + 'edge-banding-payables' => 'edge-banding.payables', + default => null, + }; + + if ($scope === null) { + return null; + } + + $filters = match ($page) { + 'edge-banding-prices' => $this->edgeBandingFilters([ + 'raw_name', 'raw_code', 'production_code', 'supplier', + ]), + 'details' => $this->edgeBandingFilters([ + 'date', 'production_code', 'raw_name', 'return_flag', + 'outbound_supplier', 'arrival_supplier', 'outbound_no', + ]), + 'edge-banding-payables' => $this->edgeBandingFilters(['period', 'supplier']), + default => [], + }; + + if ($page === 'edge-banding-payables' && ! isset($filters['period'])) { + $filters['period'] = '2026-06'; + } + + $pageNumber = max((int) request('data_page', 1), 1); + $limit = min(max((int) request('data_limit', 25), 1), 100); + $cache = $this->upstreamCaches->forSource(self::EDGE_BANDING_SOURCE); + + try { + $result = $cache->remember( + $scope, + ['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit], + fn () => match ($scope) { + 'edge-banding.prices' => $this->edgeBanding->prices($filters, $pageNumber, $limit), + 'edge-banding.details' => $this->edgeBanding->details($filters, $pageNumber, $limit), + 'edge-banding.payables' => $this->edgeBanding->payables($filters, $pageNumber, $limit), + }, + ); + + return [ + 'filters' => $filters, + 'result' => $result, + 'page' => $pageNumber, + 'limit' => $limit, + 'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1), + 'status' => $cache->responseHeaders(), + 'error' => null, + ]; + } catch (ServiceUnavailableHttpException $exception) { + return [ + 'filters' => $filters, + 'result' => ['data' => [], 'total' => 0], + 'page' => $pageNumber, + 'limit' => $limit, + 'last_page' => 1, + 'status' => [], + 'error' => $exception->getMessage(), + ]; + } catch (Throwable $exception) { + report($exception); + + return [ + 'filters' => $filters, + 'result' => ['data' => [], 'total' => 0], + 'page' => $pageNumber, + 'limit' => $limit, + 'last_page' => 1, + 'status' => [], + 'error' => '封边条数据暂时无法读取,请稍后重试。', + ]; + } + } + + private function edgeBandingFilters(array $fields): array + { + $filters = []; + + foreach ($fields as $field) { + $value = trim((string) request($field, '')); + + if ($value !== '') { + $filters[$field] = $value; + } + } + + return $filters; + } + /** * 种菜应收对账 - 加载对账数据(21字段) */ @@ -208,7 +317,7 @@ class Pages extends Controller $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); $repository = app(PinduoduoSpreadBillRepository::class); - $cache = app(UpstreamQueryCacheFactory::class)->forSource('jiansun'); + $cache = $this->upstreamCaches->forSource('jiansun'); $filters = [ 'store_name' => $storeName !== '' ? $storeName : null, 'from' => $fromDate !== '' ? $fromDate : null, diff --git a/config/jiansun.php b/config/jiansun.php index fceed500..c357d60a 100644 --- a/config/jiansun.php +++ b/config/jiansun.php @@ -25,7 +25,10 @@ return [ 'database' => env('DB_JIANSUN_DATABASE'), 'username' => env('DB_JIANSUN_USERNAME'), 'password' => env('DB_JIANSUN_PASSWORD'), - 'odbc_driver' => env('DB_JIANSUN_ODBC_DRIVER', 'FreeTDS'), + 'odbc_driver' => env( + 'DB_JIANSUN_ODBC_DRIVER', + PHP_OS_FAMILY === 'Windows' ? 'ODBC Driver 18 for SQL Server' : 'FreeTDS' + ), 'tds_version' => env('DB_JIANSUN_TDS_VERSION', '7.4'), // 旧 SQL Server 的可选 TLS 预登录不稳定;仅本地开发默认关闭协商。 'encryption' => env( diff --git a/config/operations_replica.php b/config/operations_replica.php index 3d2d7a73..5ca07856 100644 --- a/config/operations_replica.php +++ b/config/operations_replica.php @@ -24,7 +24,10 @@ return [ 'database' => env('DB_OPERATIONS_REPLICA_DATABASE'), 'username' => env('DB_OPERATIONS_REPLICA_USERNAME'), 'password' => env('DB_OPERATIONS_REPLICA_PASSWORD'), - 'odbc_driver' => env('DB_OPERATIONS_REPLICA_ODBC_DRIVER', 'FreeTDS'), + 'odbc_driver' => env( + 'DB_OPERATIONS_REPLICA_ODBC_DRIVER', + PHP_OS_FAMILY === 'Windows' ? 'ODBC Driver 18 for SQL Server' : 'FreeTDS' + ), 'tds_version' => env('DB_OPERATIONS_REPLICA_TDS_VERSION', '7.4'), // 旧 SQL Server 的可选 TLS 预登录不稳定;仅本地开发默认关闭协商。 'encryption' => env( diff --git a/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php b/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php new file mode 100644 index 00000000..2cf2ad25 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php @@ -0,0 +1,293 @@ +>,total:int} */ + public function prices(array $filters, int $page, int $limit): array + { + $where = ["p.number LIKE 'FBT%'"]; + $bindings = []; + + if (($filters['raw_name'] ?? '') !== '') { + $where[] = 'p.name LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['raw_name']); + } + if (($filters['raw_code'] ?? '') !== '') { + $where[] = 'p.number LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['raw_code']); + } + if (($filters['production_code'] ?? '') !== '') { + $where[] = "CONCAT(p.number, 'F01') LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['production_code']); + } + if (($filters['supplier'] ?? '') !== '') { + $where[] = 'p.supplier LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['supplier']); + } + + $whereSql = implode(' AND ', $where); + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $offset = ($page - 1) * $limit; + + $sql = "WITH source_rows AS ( + SELECT + p.name AS raw_name, + p.number AS raw_code, + CONCAT(p.number, 'F01') AS production_code, + CAST(p.price AS decimal(18,2)) AS latest_price, + p.supplier AS target_supplier, + ROW_NUMBER() OVER (ORDER BY p.number, p.supplier) AS row_no, + COUNT(*) OVER () AS total_count + FROM [dbo].[pm_averageUnitPrice] p + WHERE {$whereSql} + ) + SELECT raw_name, raw_code, production_code, + latest_price, target_supplier, total_count + FROM source_rows + WHERE row_no BETWEEN ? AND ? + ORDER BY row_no"; + + $rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit])); + + foreach ($rows as &$row) { + $row['price_type'] = '采购价'; + $row['factory_type'] = '封边条厂'; + } + + return $this->pageResult($rows); + } + + /** @return array{data:array>,total:int} */ + public function details(array $filters, int $page, int $limit): array + { + $where = ["s.procode LIKE 'FBT%'"]; + $bindings = []; + + if (($filters['date'] ?? '') !== '') { + $where[] = 'CONVERT(date, s.sendTime) = ?'; + $bindings[] = $filters['date']; + } + if (($filters['production_code'] ?? '') !== '') { + $where[] = 's.procode LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['production_code']); + } + if (($filters['raw_name'] ?? '') !== '') { + $where[] = 's.skuname LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['raw_name']); + } + if (($filters['return_flag'] ?? '') === 'return') { + $where[] = 'ISNULL(s.settlementStock, 0) < 0'; + } elseif (($filters['return_flag'] ?? '') === 'normal') { + $where[] = 'ISNULL(s.settlementStock, 0) >= 0'; + } + if (($filters['outbound_supplier'] ?? '') !== '') { + $where[] = 's.supplier LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['outbound_supplier']); + } + if (($filters['arrival_supplier'] ?? '') !== '') { + $where[] = 's.whName LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['arrival_supplier']); + } + if (($filters['outbound_no'] ?? '') !== '') { + // 现有结算表没有单独的出库单号,快递/送货号作为可检索的外部单号。 + $where[] = 'ISNULL(s.expressNo, \'\') LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['outbound_no']); + } + + $whereSql = implode(' AND ', $where); + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $offset = ($page - 1) * $limit; + + $sql = "WITH source_rows AS ( + SELECT + CONVERT(varchar(19), s.sendTime, 120) AS inbound_date, + s.procode AS production_code, + s.skuname AS raw_name, + CAST(ISNULL(s.settlementStock, 0) AS decimal(18,2)) AS outbound_quantity, + s.supplier AS outbound_supplier, + ISNULL(s.whName, '') AS arrival_supplier, + CONVERT(varchar(10), s.sendTime, 120) AS batch_date, + '' AS outbound_no, + ISNULL(s.remark, '') AS outbound_remark, + '' AS requisition_no, + ISNULL(s.expressNo, '') AS delivery_no, + CAST(ISNULL(s.settlementStock, 0) AS decimal(18,2)) AS received_quantity, + CAST(ISNULL(s.settlementPrice, 0) AS decimal(18,2)) AS reconciliation_amount, + CAST(0 AS decimal(18,2)) AS freight, + CAST(ISNULL(a.price, 0) AS decimal(18,2)) AS edge_band_unit_price, + CAST(ISNULL(s.settlementStock, 0) * ISNULL(a.price, 0) AS decimal(18,2)) AS payable_amount, + CAST(ISNULL(s.singlePrice, 0) AS decimal(18,2)) AS furniture_unit_price, + CAST(ISNULL(s.settlementPrice, 0) AS decimal(18,2)) AS furniture_amount, + CAST(ISNULL(s.singlePrice, 0) - ISNULL(a.price, 0) AS decimal(18,2)) AS price_difference, + CAST(ISNULL(s.settlementPrice, 0) - (ISNULL(s.settlementStock, 0) * ISNULL(a.price, 0)) AS decimal(18,2)) AS profit, + ROW_NUMBER() OVER (ORDER BY s.sendTime DESC, s.Id DESC) AS row_no, + COUNT(*) OVER () AS total_count + FROM [dbo].[fys_supplierGoods_sttlement_sale] s + LEFT JOIN [dbo].[pm_averageUnitPrice] a + ON a.number = s.procode AND a.supplier = s.supplier + WHERE {$whereSql} + ) + SELECT inbound_date, production_code, raw_name, outbound_quantity, + outbound_supplier, arrival_supplier, batch_date, + outbound_no, outbound_remark, requisition_no, delivery_no, + received_quantity, reconciliation_amount, freight, edge_band_unit_price, + payable_amount, furniture_unit_price, furniture_amount, price_difference, + profit, total_count + FROM source_rows + WHERE row_no BETWEEN ? AND ? + ORDER BY row_no"; + + $rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit])); + + foreach ($rows as &$row) { + $row['unit'] = '米'; + $row['return_flag'] = (float) ($row['outbound_quantity'] ?? 0) < 0 ? '退货' : '正常出库'; + $row['number_type'] = '申领'; + } + + return $this->pageResult($rows); + } + + /** @return array{data:array>,total:int} */ + public function payables(array $filters, int $page, int $limit): array + { + $where = [ + "EXISTS ( + SELECT 1 + FROM [dbo].[pm_averageUnitPrice] edge_price + WHERE edge_price.number LIKE 'FBT%' + AND edge_price.supplier = p.supplier + )", + ]; + $bindings = []; + + if (($filters['period'] ?? '') !== '') { + $where[] = 'p.Month = ?'; + $bindings[] = $filters['period']; + } + if (($filters['supplier'] ?? '') !== '') { + $where[] = 'p.supplier LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['supplier']); + } + + $whereSql = implode(' AND ', $where); + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $offset = ($page - 1) * $limit; + + $sql = "WITH source_rows AS ( + SELECT + p.supplier, + CAST(SUM(ISNULL(p.bginMonthPay, 0)) AS decimal(18,2)) AS month_start_unpaid, + CAST(SUM(ISNULL(p.loan, 0)) AS decimal(18,2)) AS goods_amount, + CAST(SUM(ISNULL(p.ExpFieght, 0)) AS decimal(18,2)) AS prepaid_freight, + CAST(SUM(ISNULL(p.Totalindemnity, 0)) AS decimal(18,2)) AS compensation, + CAST(SUM(ISNULL(p.supplySup, 0)) AS decimal(18,2)) AS supplier_refund, + CAST(SUM(ISNULL(p.ShouldPay, 0)) AS decimal(18,2)) AS payable_amount, + CAST(SUM(ISNULL(p.factPay, 0)) AS decimal(18,2)) AS paid_amount, + CAST(SUM(ISNULL(p.cashierbypaid, 0)) AS decimal(18,2)) AS actual_payment, + CAST(SUM(ISNULL(p.toPrivate, 0)) AS decimal(18,2)) AS private_payment, + CAST(SUM(ISNULL(p.toCorporate, 0)) AS decimal(18,2)) AS corporate_payment, + MAX(ISNULL(p.remarks, '')) AS remark, + CAST(SUM(SUM(ISNULL(p.bginMonthPay, 0))) OVER () AS decimal(18,2)) AS summary_month_start_unpaid, + CAST(SUM(SUM(ISNULL(p.loan, 0))) OVER () AS decimal(18,2)) AS summary_goods_amount, + CAST(SUM(SUM(ISNULL(p.ExpFieght, 0))) OVER () AS decimal(18,2)) AS summary_prepaid_freight, + CAST(SUM(SUM(ISNULL(p.Totalindemnity, 0))) OVER () AS decimal(18,2)) AS summary_compensation, + CAST(SUM(SUM(ISNULL(p.supplySup, 0))) OVER () AS decimal(18,2)) AS summary_supplier_refund, + CAST(SUM(SUM(ISNULL(p.ShouldPay, 0))) OVER () AS decimal(18,2)) AS summary_payable_amount, + CAST(SUM(SUM(ISNULL(p.factPay, 0))) OVER () AS decimal(18,2)) AS summary_paid_amount, + CAST(SUM(SUM(ISNULL(p.cashierbypaid, 0))) OVER () AS decimal(18,2)) AS summary_actual_payment, + CAST(SUM(SUM(ISNULL(p.toPrivate, 0))) OVER () AS decimal(18,2)) AS summary_private_payment, + CAST(SUM(SUM(ISNULL(p.toCorporate, 0))) OVER () AS decimal(18,2)) AS summary_corporate_payment, + ROW_NUMBER() OVER (ORDER BY p.supplier) AS row_no, + COUNT(*) OVER () AS total_count + FROM [dbo].[fys_supplierPaymentSummary] p + WHERE {$whereSql} + GROUP BY p.supplier + ) + SELECT supplier, month_start_unpaid, goods_amount, prepaid_freight, + compensation, supplier_refund, payable_amount, paid_amount, + actual_payment, private_payment, corporate_payment, remark, + summary_month_start_unpaid, summary_goods_amount, + summary_prepaid_freight, summary_compensation, + summary_supplier_refund, summary_payable_amount, + summary_paid_amount, summary_actual_payment, + summary_private_payment, summary_corporate_payment, + total_count + FROM source_rows + WHERE row_no BETWEEN ? AND ? + ORDER BY row_no"; + + $rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit])); + + return $this->payablesPageResult($rows); + } + + private function query(): UpstreamReadOnlyQuery + { + return $this->query ??= $this->queries->forSource(self::SOURCE); + } + + private function contains(string $value): string + { + return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], trim($value)) . '%'; + } + + /** @param array> $rows */ + private function pageResult(array $rows): array + { + $total = (int) ($rows[0]['total_count'] ?? 0); + + foreach ($rows as &$row) { + unset($row['total_count']); + } + + return ['data' => $rows, 'total' => $total]; + } + + /** @param array> $rows */ + private function payablesPageResult(array $rows): array + { + $summaryKeys = [ + 'month_start_unpaid', 'goods_amount', 'prepaid_freight', 'compensation', + 'supplier_refund', 'payable_amount', 'paid_amount', 'actual_payment', + 'private_payment', 'corporate_payment', + ]; + $totals = array_fill_keys($summaryKeys, 0.0); + + if ($rows !== []) { + foreach ($summaryKeys as $key) { + $totals[$key] = (float) ($rows[0]['summary_' . $key] ?? 0); + } + } + + foreach ($rows as &$row) { + foreach ($summaryKeys as $key) { + unset($row['summary_' . $key]); + } + } + + return array_merge($this->pageResult($rows), ['totals' => $totals]); + } +} diff --git a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php index b35cbcd9..b4d64108 100644 --- a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php +++ b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php @@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Services; use InvalidArgumentException; use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher; +use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableRepository; @@ -15,6 +16,7 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher { public function __construct( private readonly JianSunQueryCache $cache, + private readonly EdgeBandingRepository $edgeBanding, private readonly GoodsReconciliationRepository $goods, private readonly PinduoduoSpreadBillRepository $pinduoduo, private readonly SupplierPayableRepository $supplier, @@ -50,6 +52,10 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher $flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : null; return match ($scope) { + 'edge-banding.prices' => $this->edgeBanding->prices($filters, $page, $limit), + 'edge-banding.details' => $this->edgeBanding->details($filters, $page, $limit), + 'edge-banding.payables' => $this->edgeBanding->payables($filters, $page, $limit), + 'goods.list' => $this->goods->list($filters, $page, $limit), 'goods.summary' => $this->goods->summary($filters), 'goods.options' => $this->goods->filterOptions(), diff --git a/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php b/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php index 9d79c383..1f386d74 100644 --- a/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php +++ b/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php @@ -55,6 +55,16 @@ class UpstreamSourceRegistry return $configured; } + if ($suffix === 'connection') { + return $this->connection($definition['key'], (array) $configured); + } + + // Hosts and credentials come from the shared record, while the ODBC + // driver must match the operating system that runs this application. + if ($suffix === 'connection.odbc_driver' && trim((string) $configured) !== '') { + return $configured; + } + return $this->storedConfiguration->value($definition['key'], $suffix, $configured); } @@ -67,4 +77,30 @@ class UpstreamSourceRegistry { return (bool) config('upstreams.local_configuration.enabled', false); } + + private function connection(string $source, array $configured): array + { + $fields = array_values(array_unique(array_merge(array_keys($configured), [ + 'host', + 'port', + 'database', + 'username', + 'password', + 'odbc_driver', + 'tds_version', + 'connect_timeout', + 'query_timeout', + ]))); + $resolved = $configured; + + foreach ($fields as $field) { + $resolved[$field] = $this->value( + $source, + 'connection.' . $field, + $configured[$field] ?? null, + ); + } + + return $resolved; + } } diff --git a/resources/views/business/pages/edge-banding-materials/_styles.blade.php b/resources/views/business/pages/edge-banding-materials/_styles.blade.php index ba3dd78c..cd061f25 100644 --- a/resources/views/business/pages/edge-banding-materials/_styles.blade.php +++ b/resources/views/business/pages/edge-banding-materials/_styles.blade.php @@ -1,6 +1,8 @@