commit15
This commit is contained in:
@@ -1,10 +1,26 @@
|
||||
<?php
|
||||
namespace App\Modules\Identity\Http;
|
||||
use App\Modules\Identity\Services\ModuleAssociationReview;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
final class ModuleRecordsController
|
||||
{
|
||||
public function index(Request $request, ModuleAssociationReview $review) { return response()->json($review->listing($request))->header('Cache-Control', 'no-store'); }
|
||||
public function preview(Request $request, ModuleAssociationReview $review) { return response()->json($review->preview($request))->header('Cache-Control', 'no-store'); }
|
||||
public function associate(Request $request, ModuleAssociationReview $review) { return response()->json($review->confirm($request))->header('Cache-Control', 'no-store'); }
|
||||
public function preview(Request $request, ModuleAssociationReview $review) { return $this->decision(fn () => $review->preview($request)); }
|
||||
public function associate(Request $request, ModuleAssociationReview $review) { return $this->decision(fn () => $review->confirm($request)); }
|
||||
|
||||
private function decision(callable $operation)
|
||||
{
|
||||
try {
|
||||
return response()->json($operation())->header('Cache-Control', 'no-store');
|
||||
} catch (QueryException $e) {
|
||||
// The service transaction has already rolled back. Do not retry a
|
||||
// decision automatically or expose SQL/bindings in the response.
|
||||
if (($e->errorInfo[0] ?? null) !== '55P03') throw $e;
|
||||
return response()->json([
|
||||
'code' => 'association_busy',
|
||||
'message' => 'A conferência está ocupada por outra operação. Aguarde e volte a confirmar; o estado será novamente validado.',
|
||||
], 423)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,24 +101,112 @@ final class ModuleAssociationReview
|
||||
default => null,
|
||||
};
|
||||
if (($d['search'] ?? '') !== '') $query->where('name', 'ilike', '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $d['search']).'%');
|
||||
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, ['id', 'name', 'version'], 'page', $d['page'] ?? 1);
|
||||
$rows = [];
|
||||
foreach ($page->items() as $row) {
|
||||
$links = $this->links($area, $row->id);
|
||||
$deps = []; $restricted = false;
|
||||
try {
|
||||
$graph = []; $this->graph($area, $row->id, $graph);
|
||||
unset($graph["$area:$row->id"]);
|
||||
$deps = array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $graph));
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $e) { $restricted = true;
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
|
||||
$rows[] = [...(array) $row, ...$links, 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
|
||||
}
|
||||
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, $this->pageColumns($area, true), 'page', $d['page'] ?? 1);
|
||||
$rows = $this->describePage($area, $page->items(), $scope);
|
||||
return ['records' => [$area => $rows], 'modules' => app(ModuleNavigation::class)->available($this->context),
|
||||
'pagination' => ['page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
|
||||
'notice' => 'Cada decisão exige conferência individual. Dependências não são associadas automaticamente.'];
|
||||
}
|
||||
|
||||
/** Request-local data only: the selected page and its explicit dependency closure.
|
||||
* Never reused by preview/confirm, which still read and lock current state.
|
||||
*/
|
||||
private function describePage(string $area, array $roots, array $scope): array
|
||||
{
|
||||
$records = []; $edges = []; $scopes = [$area => $scope]; $pending = [];
|
||||
foreach ($roots as $row) { $records[$area][$row->id] = $row; $pending["$area:$row->id"] = [$area, (int) $row->id]; }
|
||||
while ($pending) {
|
||||
$needed = [];
|
||||
foreach ($pending as $key => [$kind, $id]) {
|
||||
$row = $records[$kind][$id]; $refs = [];
|
||||
if (isset($row->client_id)) $refs[] = ['clients', (int) $row->client_id];
|
||||
if (isset($row->source_id)) $refs[] = ['service_drafts', (int) $row->source_id];
|
||||
if (str_ends_with($kind, '_drafts')) {
|
||||
foreach (json_decode($row->association_service_ids, true, 512, JSON_THROW_ON_ERROR) as $serviceId) $refs[] = ['services', (int) $serviceId];
|
||||
}
|
||||
$edges[$key] = $refs;
|
||||
foreach ($refs as [$depArea, $depId]) if (!array_key_exists($depId, $records[$depArea] ?? [])) $needed[$depArea][$depId] = $depId;
|
||||
}
|
||||
$pending = [];
|
||||
foreach ($needed as $depArea => $ids) {
|
||||
if (!array_key_exists($depArea, $scopes)) {
|
||||
try { $scopes[$depArea] = $this->scope($depArea); }
|
||||
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $scopes[$depArea] = null; }
|
||||
}
|
||||
foreach ($ids as $id) $records[$depArea][$id] = null;
|
||||
if ($scopes[$depArea] === null) continue;
|
||||
foreach (array_chunk(array_values($ids), 500) as $chunk) {
|
||||
foreach (DB::table('a_'.$depArea)->where($scopes[$depArea])->whereIn('id', $chunk)->get($this->pageColumns($depArea)) as $row) {
|
||||
$records[$depArea][$row->id] = $row;
|
||||
$pending["$depArea:$row->id"] = [$depArea, (int) $row->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$states = $this->pageStates($records);
|
||||
$out = [];
|
||||
foreach ($roots as $row) {
|
||||
$deps = []; $restricted = false;
|
||||
try { $seen = []; $this->walkPage($area, $row->id, $records, $edges, $states, $seen); unset($seen["$area:$row->id"]); $deps = array_values($seen); }
|
||||
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
|
||||
$out[] = ['id' => $row->id, 'name' => $row->name, 'version' => $row->version, ...$states[$area][$row->id], 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Fetch metadata and ordered references, not document bodies. This projection
|
||||
* is only for listing; the decision snapshot still hashes the complete rows.
|
||||
*/
|
||||
private function pageColumns(string $area, bool $root = false): array
|
||||
{
|
||||
$columns = $root ? ['id', 'name', 'version'] : ['id', 'version'];
|
||||
if (str_ends_with($area, '_drafts')) {
|
||||
$columns[] = 'client_id';
|
||||
if ($area === 'billing_drafts') $columns[] = 'source_id';
|
||||
$columns[] = DB::raw("(SELECT coalesce(jsonb_agg(ref.line->>'service_id' ORDER BY ref.ord)
|
||||
FILTER (WHERE ref.line->>'service_id' IS NOT NULL),'[]'::jsonb)
|
||||
FROM jsonb_array_elements(data->'lines') WITH ORDINALITY AS ref(line,ord)) AS association_service_ids");
|
||||
}
|
||||
return $columns;
|
||||
}
|
||||
|
||||
private function walkPage(string $area, int $id, array $records, array $edges, array $states, array &$seen): void
|
||||
{
|
||||
$key = "$area:$id";
|
||||
if (isset($seen[$key])) return;
|
||||
$row = $records[$area][$id] ?? null;
|
||||
abort_unless($row, 403, 'Dependência fora do âmbito autorizado.');
|
||||
$seen[$key] = ['area' => $area, 'id' => $id, 'version' => (int) $row->version, ...$states[$area][$id]];
|
||||
foreach ($edges[$key] ?? [] as [$depArea, $depId]) $this->walkPage($depArea, $depId, $records, $edges, $states, $seen);
|
||||
}
|
||||
|
||||
/** SQL reduces history to one state per selected record; no event universe in PHP.
|
||||
* Review-only events advance revision but never create an active association.
|
||||
*/
|
||||
private function pageStates(array $records): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($records as $area => $items) {
|
||||
$ids = [];
|
||||
foreach ($items as $id => $row) if ($row !== null) { $ids[] = (string) $id; $out[$area][$id] = ['modules' => [], 'revision' => 0]; }
|
||||
foreach (array_chunk($ids, 500) as $chunk) {
|
||||
$q = DB::table('identity_audit')->where($this->context->identifiers())
|
||||
->whereIn('action', ['module.record.associated', 'module.record.disassociated', 'module.record.reviewed'])
|
||||
->whereRaw("details::jsonb->>'area' = ?", [$area])->whereIn(DB::raw("details::jsonb->>'record_id'"), $chunk)
|
||||
->selectRaw("id,action,details::jsonb->>'record_id' AS record_id,details::jsonb->>'module_id' AS module_id");
|
||||
$sql = "WITH scoped AS MATERIALIZED (".$q->toSql()."),
|
||||
latest AS (SELECT DISTINCT ON (record_id,module_id) record_id,module_id,action FROM scoped
|
||||
WHERE action <> 'module.record.reviewed' ORDER BY record_id,module_id,id DESC),
|
||||
revisions AS (SELECT record_id,max(id) AS revision FROM scoped GROUP BY record_id)
|
||||
SELECT r.record_id,r.revision,coalesce(jsonb_agg(l.module_id ORDER BY l.module_id)
|
||||
FILTER (WHERE l.action='module.record.associated'),'[]'::jsonb) AS modules
|
||||
FROM revisions r LEFT JOIN latest l ON l.record_id=r.record_id GROUP BY r.record_id,r.revision";
|
||||
foreach (DB::select($sql, $q->getBindings()) as $row) $out[$area][(int) $row->record_id] = ['modules' => json_decode($row->modules, true, 512, JSON_THROW_ON_ERROR), 'revision' => (int) $row->revision];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function preview(Request $r): array
|
||||
{
|
||||
$d = $this->input($r);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory;
|
||||
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require_once __DIR__.'/../../../packages/inventory-read/bootstrap.php';
|
||||
|
||||
final class StockReadController
|
||||
{
|
||||
public function index(Request $r, CompanyContext $context, Permissions $permissions)
|
||||
{
|
||||
$d = $r->validate(['view' => 'sometimes|in:items,warehouses,balances,movements','search' => 'nullable|string|max:100',
|
||||
'warehouse_id' => 'sometimes|integer|min:1','page' => 'sometimes|integer|min:1|max:100000','per_page' => 'sometimes|integer|min:1|max:100',
|
||||
'snapshot' => 'sometimes|string|max:8192']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [],422);
|
||||
$filters = ['view' => $d['view'] ?? 'balances','search' => $d['search'] ?? '', 'warehouse_id' => isset($d['warehouse_id']) ? (int) $d['warehouse_id'] : null,
|
||||
'page' => (int) ($d['page'] ?? 1), 'per_page' => (int) ($d['per_page'] ?? 25)];
|
||||
$scope = $context->identifiers(); $unit = $context->unitId(); abort_unless($unit,403);
|
||||
$permissions->require($context->userId(), $scope['tenant_id'], $scope['company_id'], 'stock.view', $unit);
|
||||
$key = $scope['tenant_id'].':'.$scope['company_id'].':'.$unit;
|
||||
$binding = config('stock_read.synthetic_sources.'.$key);
|
||||
if (!$binding) return response()->json(['status' => 'unavailable','message' => 'Fonte autorizada de stock ainda não ligada a este contexto.'],503)->header('Cache-Control','no-store');
|
||||
// A config edit cannot bind this experimental reader to a client database.
|
||||
abort_unless(app()->environment('testing'),403); TestingDatabaseGuard::assertSafe();
|
||||
try {
|
||||
$connection=DB::connection();
|
||||
if(isset($binding['connection'])) {
|
||||
// QA integration may observe the actual native receipt book through
|
||||
// a SELECT-only role, instead of copying movements to another book.
|
||||
abort_unless($binding['connection']==='stock_receipt_source'&&($binding['schema']??null)==='public',503,'Fonte de stock incompatível.');
|
||||
$connection=DB::connection('stock_receipt_source');
|
||||
abort_unless($connection->getDriverName()==='pgsql'&&$connection->getConfig('host')==='127.0.0.1'&&(int)$connection->getConfig('port')===55439,403);
|
||||
$i=$connection->selectOne("SELECT current_database() AS name,current_user AS role,current_setting('cluster_name') AS cluster,current_setting('default_transaction_read_only') AS read_only");
|
||||
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
|
||||
abort_unless(preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name,$m)&&$i->role==='stock_receipt_read_'.$m[1]
|
||||
&&preg_match('/^[a-f0-9]{12}$/D',$run)&&$i->cluster==='sgf_modular_regression_'.$run&&$i->read_only==='on',403);
|
||||
$rights=$connection->selectOne("SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls AS elevated,
|
||||
has_table_privilege(current_user,'public.stock_movements','INSERT,UPDATE,DELETE') AS writes FROM pg_roles WHERE rolname=current_user");
|
||||
abort_if($rights->elevated||$rights->writes,403);
|
||||
// Bind the token to the actual book too, not just the logical connection name.
|
||||
$binding['physical_source']=['database'=>$i->name,'role'=>$i->role,'cluster'=>$i->cluster];
|
||||
} else abort_unless(preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$binding['schema']??''),503,'Fonte de stock incompatível.');
|
||||
abort_unless(($binding['append_only']??false)===true,503,'Fonte de stock incompatível.');
|
||||
if ($filters['warehouse_id'] !== null) abort_unless(in_array($filters['warehouse_id'], $binding['warehouse_ids'],true),403);
|
||||
$fingerprint = hash('sha256', json_encode([$scope,$unit,$context->userId(),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
|
||||
$result = (new \Tempo\Inventory\InventoryReadSession($connection))->read($binding,$fingerprint,$filters,$d['snapshot'] ?? null);
|
||||
} catch (\PDOException $e) {
|
||||
$sqlstate=$e->errorInfo[0] ?? (string)$e->getCode();
|
||||
if (!str_starts_with($sqlstate,'08')&&!in_array($sqlstate,['28P01','3D000','42P01','42703','42883','57014'],true)) throw $e;
|
||||
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
|
||||
}
|
||||
return response()->json($result)->header('Cache-Control','no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\HistoricalPayments;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
|
||||
/** Reads explicitly reconciled immutable receipts; never imports or decides payments. */
|
||||
final class SupplierHistoryReceipts
|
||||
{
|
||||
public const VERSION = 'SGF-PAYABLE-HISTORY-RECEIPT-SIM-v1';
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
SupplierPayables::guard();
|
||||
|
||||
return config('supplier_payable_history.synthetic_enabled') === true
|
||||
&& DB::getSchemaBuilder()->hasTable('pa_source_histories')
|
||||
&& (new HistoricalPayments(DB::connection(), []))->available();
|
||||
}
|
||||
|
||||
public function payments(User $user, int $localId): array
|
||||
{
|
||||
abort_unless($this->available(), 503, 'Recibos históricos não ligados neste ensaio.');
|
||||
$adapter = app(SupplierPayables::class);
|
||||
$scope = $adapter->scope($user);
|
||||
abort_unless($adapter->can($user, 'view'), 403);
|
||||
$source = $adapter->document($user, $localId);
|
||||
$receipt = DB::table('pa_source_histories')->where($scope)->where('local_source_id', $localId)->orderByDesc('version')->first();
|
||||
|
||||
return $this->decode($source, $receipt);
|
||||
}
|
||||
|
||||
/** Local read snapshots only; no command obtains its source through this batch. */
|
||||
public function page(User $user, array $documents): array
|
||||
{
|
||||
abort_unless($this->available(), 503);
|
||||
$adapter = app(SupplierPayables::class); $scope = $adapter->scope($user);
|
||||
abort_unless($adapter->can($user, 'view'), 403);
|
||||
$receipts = DB::table('pa_source_histories')->where($scope)->whereIn('local_source_id', array_keys($documents))
|
||||
->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id');
|
||||
$result = [];
|
||||
foreach ($documents as $id => $source) {
|
||||
try { $result[$id] = $this->decode($source, $receipts->get($id)); }
|
||||
catch (\DomainException $e) { $result[$id] = $e; }
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function decode(array $source, ?object $receipt): array
|
||||
{
|
||||
if (! $receipt) {
|
||||
throw new \DomainException('Histórico não reconciliado para esta origem e contexto.');
|
||||
}
|
||||
$payload = json_decode($receipt->payload, true, 64, JSON_THROW_ON_ERROR);
|
||||
if (! is_array($payload) || ! hash_equals($receipt->receipt_hash, hash('sha256', Payables::canonical($payload)))) {
|
||||
throw new \DomainException('Recibo histórico divergente.');
|
||||
}
|
||||
if (!(($payload['version'] ?? null) === self::VERSION
|
||||
&& ($payload['source_key'] ?? null) === Payables::identity($source)
|
||||
&& ($payload['source_revision'] ?? null) === Payables::revision($source)
|
||||
&& ($payload['receipt_version'] ?? null) === (int) $receipt->version
|
||||
&& is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) {
|
||||
throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.');
|
||||
}
|
||||
$capture = HistoricalPayments::capture($source, $payload['payments']);
|
||||
if (($payload['history_hash'] ?? null) !== $capture['history_hash']) {
|
||||
throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.');
|
||||
}
|
||||
|
||||
return $capture['payments'];
|
||||
}
|
||||
}
|
||||
@@ -38,36 +38,88 @@ final class SupplierPayables
|
||||
public function document(User $u, int $id): array
|
||||
{
|
||||
$row = DB::table('pa_imported_sources')->where($this->scope($u))->where('id', $id)->first() ?? abort(403, 'Origem não mapeada neste contexto.');
|
||||
|
||||
return $this->receiptDocument($row);
|
||||
}
|
||||
|
||||
private function receiptDocument(object $row): array
|
||||
{
|
||||
$s = json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR);
|
||||
abort_unless(hash_equals($row->receipt_hash, hash('sha256', Payables::canonical($s))), 409, 'Recibo de origem divergente.');
|
||||
$s['local_id'] = $id;
|
||||
$s['local_id'] = (int) $row->id;
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function engine(User $u): Payables
|
||||
{
|
||||
return $this->buildEngine($u);
|
||||
}
|
||||
|
||||
private function buildEngine(User $u, ?array $documents = null, ?array $histories = null): Payables
|
||||
{
|
||||
$scope = $this->scope($u);
|
||||
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
|
||||
abort_unless($person, 403);
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, function (string $a) use ($u, $scope, $person) {
|
||||
$authorize = function (string $a, bool $lock = true) use ($u, $scope, $person) {
|
||||
self::guard();
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
|
||||
if ($lock) {
|
||||
DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail();
|
||||
DB::table('users')->where('id', $u->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.');
|
||||
abort_unless($this->scope($u) === $scope, 409, 'Contexto mudou.');
|
||||
app(CompanyContext::class)->selectFor($u->id, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
|
||||
abort_unless($this->can($u, 'view') && $this->can($u, $a), 403);
|
||||
}, fn (int $id) => $this->document($u, $id));
|
||||
};
|
||||
$source = $documents === null ? fn (int $id) => $this->document($u, $id) : fn (int $id) => $documents[$id] ?? abort(403);
|
||||
$history = $histories === null ? fn (int $id) => app(SupplierHistoryReceipts::class)->payments($u, $id) : function (int $id) use ($histories) {
|
||||
$result = $histories[$id] ?? new \DomainException('Histórico não reconciliado nesta página.');
|
||||
if ($result instanceof \DomainException) { throw $result; }
|
||||
return $result;
|
||||
};
|
||||
|
||||
return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source,
|
||||
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false));
|
||||
}
|
||||
|
||||
public function listing(User $u): array
|
||||
public function listing(User $u, array $options = []): array
|
||||
{
|
||||
$d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all());
|
||||
$own = DB::transactionLevel() === 0;
|
||||
return DB::transaction(function () use ($u, $options, $own) {
|
||||
if ($own) { DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); }
|
||||
$scope = $this->scope($u); abort_unless($this->can($u, 'view'), 403);
|
||||
$page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25);
|
||||
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
|
||||
abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
|
||||
$query = DB::table('pa_imported_sources AS r')->where($scope);
|
||||
if ($q !== '') {
|
||||
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
|
||||
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
|
||||
}
|
||||
if ($recognition !== 'all') {
|
||||
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
|
||||
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
|
||||
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
|
||||
}
|
||||
$pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = [];
|
||||
foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); }
|
||||
$provider = app(SupplierHistoryReceipts::class);
|
||||
$histories = $provider->available() ? $provider->page($u, $documents) : [];
|
||||
$d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage);
|
||||
$d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
|
||||
$d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
$d['history_import_available'] = app(SupplierHistoryReceipts::class)->available();
|
||||
$d['history_decisions_available'] = false;
|
||||
if ($d['history_import_available']) {
|
||||
$d['history_receipt_notice'] = 'Recibo histórico de ensaio: a origem é uma fotografia conferida. Não há leitura histórica em tempo real nem passagem operacional de responsabilidade validada.';
|
||||
}
|
||||
$d['payments_available'] = app()->environment('payable-testing') && DB::connection()->getSchemaBuilder()->hasTable('ps_payments') && $this->can($u, 'payments_view');
|
||||
|
||||
return $d;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,24 @@ use Tempo\Finance\Payables\Workspace;
|
||||
|
||||
final class SupplierPayablesController extends Controller
|
||||
{
|
||||
private function options(Request $r): array
|
||||
{
|
||||
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
|
||||
'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100',
|
||||
'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
|
||||
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
public function index(Request $r, SupplierPayables $s)
|
||||
{
|
||||
return response()->json($s->listing($r->user()))->header('Cache-Control', 'no-store');
|
||||
return response()->json($s->listing($r->user(), $this->options($r)))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r, SupplierPayables $s)
|
||||
{
|
||||
$data = $s->listing($r->user());
|
||||
$data = $s->listing($r->user(), $this->options($r));
|
||||
$scope = $s->scope($r->user());
|
||||
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
|
||||
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop\Http;
|
||||
use App\Modules\Workshop\WorkshopStock;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class WorkshopStockController
|
||||
{
|
||||
private function response(callable $read)
|
||||
{
|
||||
try{return response()->json($read())->header('Cache-Control','no-store');}
|
||||
catch(\Illuminate\Database\QueryException $e){
|
||||
$code=$e->errorInfo[0]??null;
|
||||
if($code==='55P03')return response()->json(['code'=>'workshop_stock_busy','message'=>'Consumo ocupado por outra operação. Aguarde e repita manualmente; o estado será revalidado.'],423)->header('Cache-Control','no-store');
|
||||
if(in_array($code,['42P01','42703'],true))return response()->json(['code'=>'workshop_stock_unavailable','message'=>'Fonte de consumo indisponível ou incompatível.'],503)->header('Cache-Control','no-store');
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public function index(Request $r,WorkshopStock $stock){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','proposal_page'=>'sometimes|integer|min:1|max:100000','status'=>'sometimes|in:all,pending,confirmed,withdrawn,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $this->response(fn()=>\Illuminate\Support\Facades\DB::transaction(function()use($stock,$d){\Illuminate\Support\Facades\DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $stock->listing((int)($d['page']??1),(int)($d['proposal_page']??1),$d['status']??'all');}));}
|
||||
public function withdraw(Request $r,int $id,WorkshopStock $stock){return $this->close($r,$id,$stock,'withdrawn');}
|
||||
public function reject(Request $r,int $id,WorkshopStock $stock){return $this->close($r,$id,$stock,'rejected');}
|
||||
private function close(Request $r,int $id,WorkshopStock $stock,string $state){$d=$r->validate(['state_version'=>'required|integer|min:1|max:100000','source_hash'=>'required|string|regex:/^[a-f0-9]{64}$/D','reason'=>'required|string|min:10|max:1000']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);return $this->response(fn()=>$stock->close($id,$state,(int)$d['state_version'],$d['source_hash'],$d['reason']));}
|
||||
public function prepare(Request $r,WorkshopStock $stock){$d=$r->validate(['entry_id'=>'required|integer|min:1','version'=>'required|integer|min:1','warehouse_id'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000','evidence'=>'required|string|min:3|max:500']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$key=(string)$r->header('Idempotency-Key');abort_unless(\Illuminate\Support\Str::isUuid($key),422,'Chave de repetição obrigatória.');return $this->response(fn()=>$stock->prepare($d,$key));}
|
||||
public function confirm(Request $r,int $id,WorkshopStock $stock){$d=$r->validate(['reason'=>'required|string|min:10|max:1000','received_in_warehouse'=>'sometimes|boolean']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);return $this->response(fn()=>$stock->confirm($id,$d['reason'],(bool)($d['received_in_warehouse']??false)));}
|
||||
public function prepareDelivery(Request $r,WorkshopStock $stock){return $this->delivery($r,$stock,false);}
|
||||
public function prepareAllocation(Request $r,WorkshopStock $stock){return $this->delivery($r,$stock,true);}
|
||||
public function prepareReturn(Request $r,WorkshopStock $stock){return $this->delivery($r,$stock,false,true);}
|
||||
private function delivery(Request $r,WorkshopStock $stock,bool $partial,bool $returning=false){$d=$r->validate(['entry_id'=>'required|integer|min:1','version'=>'required|integer|min:1','warehouse_id'=>'required|integer|min:1','movement_id'=>'required|integer|min:1','reason'=>'required|string|min:10|max:1000','evidence'=>'required|string|min:3|max:500']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$key=(string)$r->header('Idempotency-Key');abort_unless(\Illuminate\Support\Str::isUuid($key),422,'Chave de repetição obrigatória.');return $this->response(fn()=>$stock->prepareDelivery($d,$key,$partial,$returning));}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
namespace App\Modules\Workshop;
|
||||
|
||||
use App\Modules\Identity\Services\{Audit,Permissions};
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
require_once __DIR__.'/../../../packages/inventory-issue/bootstrap.php';
|
||||
require_once __DIR__.'/../../../packages/inventory-return/bootstrap.php';
|
||||
|
||||
final class WorkshopStock
|
||||
{
|
||||
private array $allocationTables=[];
|
||||
public function __construct(private CompanyContext $context,private Permissions $permissions){}
|
||||
private function scope(string $right): array
|
||||
{
|
||||
WorkshopWorkflow::guard();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($s['unit_id'],403);
|
||||
$actor=$this->context->userId();$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);
|
||||
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.'.$right] as $p)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);return $s;
|
||||
}
|
||||
private function binding(array $s): array
|
||||
{
|
||||
$b=config('workshop_stock.synthetic_sources.'.implode(':',$s));
|
||||
abort_unless($b&&preg_match('/^sgf_stock_read_[a-f0-9]{12}$/D',$b['schema']??'')&&!isset($b['connection'])
|
||||
&&($b['lots']??true)===false&&($b['reservations']??true)===false&&($b['spare_parts']??true)===false,503,'Fonte local de consumo ainda não ligada ou fora do recorte sem lotes/reservas/peças.');return $b;
|
||||
}
|
||||
private function lock(array $s): void {DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();}
|
||||
private function allocationTable(array $b): ?string
|
||||
{
|
||||
$table=$b['schema'].'.ws_allocations';
|
||||
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);
|
||||
return $this->allocationTables[$table]?$table:null;
|
||||
}
|
||||
private function returnTable(array $b): ?string
|
||||
{
|
||||
$table=$b['schema'].'.ws_returns';if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);return $this->allocationTables[$table]?$table:null;
|
||||
}
|
||||
private function closureTable(array $b): ?string
|
||||
{
|
||||
$table=$b['schema'].'.ws_closures';
|
||||
if(!array_key_exists($table,$this->allocationTables))$this->allocationTables[$table]=DB::getSchemaBuilder()->hasTable($table);
|
||||
return $this->allocationTables[$table]?$table:null;
|
||||
}
|
||||
private function closure(array $s,array $b,int $id): ?object
|
||||
{
|
||||
return ($table=$this->closureTable($b))?DB::table($table)->where($s)->where('proposal_id',$id)->first():null;
|
||||
}
|
||||
private function proposalResult(array $s,array $b,object $p): array
|
||||
{
|
||||
$closed=$this->closure($s,$b,(int)$p->id);$claim=$this->proposalClaim($b,(int)$p->id);
|
||||
return ['id'=>(int)$p->id,'impact'=>$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR)),
|
||||
'reason'=>$p->reason,'evidence'=>$p->evidence,'source_hash'=>$p->source_hash,
|
||||
'state'=>$closed?->state??($claim?'confirmed':'pending'),'state_version'=>($closed||$claim)?2:1,
|
||||
'movement_id'=>$claim?(int)$claim->movement_id:null,'replayed'=>true];
|
||||
}
|
||||
public function close(int $id,string $action,int $version,string $sourceHash,string $reason): array
|
||||
{
|
||||
abort_unless(in_array($action,['withdrawn','rejected'],true),422);
|
||||
$right=$action==='withdrawn'?'prepare':'confirm';$s=$this->scope($right);$b=$this->binding($s);$actor=$this->context->userId();
|
||||
return DB::transaction(function()use($id,$action,$version,$sourceHash,$reason,$right,$s,$b,$actor){
|
||||
$this->lock($s);$this->scope($right);$table=$this->closureTable($b);abort_unless($table,503,'Encerramento de propostas ainda não instalado nesta fonte.');
|
||||
$p=DB::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);
|
||||
abort_unless($action==='withdrawn'?(int)$p->preparer_id===$actor:(int)$p->preparer_id!==$actor,403,'Retirada exige o preparador; recusa exige outra identidade autorizada.');
|
||||
abort_unless($version===1&&hash_equals($p->source_hash,$sourceHash),409,'A proposta apresentada mudou. Consulte novamente o histórico.');
|
||||
if($old=$this->closure($s,$b,$id)){
|
||||
abort_unless($old->state===$action&&(int)$old->actor_id===$actor&&$old->reason===$reason,409,'Proposta já encerrada por outra decisão.');
|
||||
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>(int)$old->id,'replayed'=>true,'physical_effect_milli'=>'0'];
|
||||
}
|
||||
abort_if($this->proposalClaim($b,$id),409,'Tratamento já confirmado; exige correcção física própria.');
|
||||
// Closing an obsolete preview deliberately does not execute or revalidate its physical effect.
|
||||
$decision=DB::table($table)->insertGetId($s+['proposal_id'=>$id,'actor_id'=>$actor,'source_hash'=>$sourceHash,'reason'=>$reason,'before_state'=>'pending','state'=>$action,'state_version'=>2,'created_at'=>now()]);
|
||||
Audit::record('workshop_stock.'.$action,$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['area'=>'workshop_stock_proposal','record_id'=>$id,'decision_id'=>$decision,'version'=>2,'review_hash'=>$sourceHash,'reason'=>$reason]);
|
||||
return ['id'=>$id,'state'=>$action,'state_version'=>2,'decision_id'=>$decision,'replayed'=>false,'physical_effect_milli'=>'0'];
|
||||
});
|
||||
}
|
||||
private function originClaimed(array $b,string $origin): bool
|
||||
{
|
||||
return DB::table($b['schema'].'.ws_claims')->where('origin_key',$origin)->exists()||(($t=$this->allocationTable($b))&&DB::table($t)->where('origin_key',$origin)->exists())||(($t=$this->returnTable($b))&&DB::table($t)->where('origin_key',$origin)->exists());
|
||||
}
|
||||
private function proposalClaim(array $b,int $id): ?object
|
||||
{
|
||||
return DB::table($b['schema'].'.ws_claims')->where('proposal_id',$id)->first()??(($t=$this->allocationTable($b))?DB::table($t)->where('proposal_id',$id)->first():null)??(($t=$this->returnTable($b))?DB::table($t)->where('proposal_id',$id)->first():null);
|
||||
}
|
||||
private function source(array $s,array $b,int $id,int $version,int $warehouse,bool $lock=false,bool $existingDelivery=false,string $event='consumed'): array
|
||||
{
|
||||
$q=DB::table('wo_entries AS e')->join('wo_orders AS o','o.id','=','e.order_id')->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->where('e.id',$id)->select('e.*');if($lock)$q->lockForUpdate();
|
||||
$e=$q->first();abort_unless($e,403);$d=json_decode($e->data,true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(in_array((int)$e->order_id,$b['eligible_order_ids'],true),409,'Origem não reconciliada para este circuito; não se incorporam observações antigas automaticamente.');
|
||||
abort_unless($e->kind==='material'&&($d['event']??null)===$event&&($d['owner']??null)==='company',422,'Ocorrência ou propriedade incompatível com o tratamento escolhido.');
|
||||
abort_unless((int)$e->version===$version&&!$e->outside_authority&&!DB::table('wo_entries')->where('previous_id',$id)->exists(),409,'Origem antiga ou fora da autorização. Faça nova conferência.');
|
||||
$decision=DB::table('wo_decisions')->where(['entry_id'=>$id,'entry_version'=>$version,'decision'=>'verified'])->first();abort_unless($decision&&(int)$decision->actor_id!==(int)$e->actor_id,409,'Consumo sem verificação técnica independente.');
|
||||
$map=$b['resources'][$d['resource'].'|'.$d['unit']]??null;abort_unless($map&&in_array($warehouse,$map['warehouse_ids'],true),403,'Sem correspondência explícita de recurso/unidade/armazém.');
|
||||
$item=DB::table($b['schema'].'.stock_items')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$map['stock_item_id']])->first();
|
||||
abort_unless($item&&$item->unit===$map['stock_unit']&&strtoupper(trim($item->unit))===$d['unit'],409,'Unidade de stock incompatível; sem conversão automática.');
|
||||
abort_unless(DB::table($b['schema'].'.warehouses')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$warehouse])->exists(),403);
|
||||
try{$quantity=BigDecimal::of((string)$e->quantity)->multipliedBy(1000)->toScale(0)->toInt();}catch(\Brick\Math\Exception\MathException){abort(422,'Quantidade não representável exactamente em milésimos.');}abort_unless($quantity>0,422);
|
||||
$origin=hash('sha256',json_encode([$s,$e->order_id,$e->origin],JSON_THROW_ON_ERROR));
|
||||
// Do not deduct consumption again after an unlinked physical delivery/issue.
|
||||
$unknownQuery=DB::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$item->id,'m.warehouse_id'=>$warehouse])->where('m.quantity_milli','<',0)
|
||||
->whereNotExists(fn($x)=>$x->selectRaw('1')->from($b['schema'].'.ws_claims AS c')->whereColumn('c.movement_id','m.id'));
|
||||
$accounted='0';if($t=$this->allocationTable($b))$accounted.=' + (SELECT coalesce(sum(a.quantity_milli),0) FROM '.$t.' AS a WHERE a.movement_id=m.id)';
|
||||
if($t=$this->returnTable($b))$accounted.=' + (SELECT coalesce(sum(r.quantity_milli),0) FROM '.$t.' AS r WHERE r.parent_movement_id=m.id)';
|
||||
$unknownQuery->whereRaw('('.$accounted.') < -m.quantity_milli');
|
||||
$unknown=$unknownQuery->exists();
|
||||
abort_if($unknown&&!$existingDelivery,409,'Existe saída física sem correspondência reconciliada. Não se volta a abater a entrega como consumo.');
|
||||
$book=DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$item->id,'warehouse_id'=>$warehouse])->selectRaw('coalesce(sum(quantity_milli),0)::text AS quantity,coalesce(max(id),0)::text AS revision')->first();
|
||||
abort_unless($existingDelivery||BigDecimal::of($book->quantity)->isGreaterThanOrEqualTo($quantity),409,'Stock físico insuficiente.');
|
||||
return ['entry'=>(array)$e,'decision'=>(array)$decision,'binding'=>$b,'origin_key'=>$origin,'stock_item_id'=>(int)$item->id,'warehouse_id'=>$warehouse,'quantity_milli'=>$quantity,'unit'=>$item->unit,'book'=>(array)$book];
|
||||
}
|
||||
private function hash(array $v): string {return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
|
||||
private function impact(array $v): array
|
||||
{
|
||||
$linked=isset($v['delivery']);$return=$linked&&($v['delivery']['returning']??false);$mode=$linked?($return?'return_existing_delivery':(($v['delivery']['partial']??false)?'allocate_existing_delivery':'link_existing_delivery')):'new_issue';
|
||||
return ['mode'=>$mode,'physical_before'=>$v['book']['quantity'],'quantity_milli'=>$linked?'0':(string)$v['quantity_milli'],'return_quantity_milli'=>$return?(string)$v['quantity_milli']:null,'linked_quantity_milli'=>$linked?(string)$v['quantity_milli']:null,'unallocated_before'=>$v['delivery']['remaining_milli']??null,'unallocated_after'=>isset($v['delivery']['remaining_milli'])?(string)BigDecimal::of($v['delivery']['remaining_milli'])->minus($v['quantity_milli']):null,'existing_movement_id'=>$v['delivery']['movement']['id']??null,'physical_after'=>$return?(string)BigDecimal::of($v['book']['quantity'])->plus($v['quantity_milli']):($linked?$v['book']['quantity']:(string)BigDecimal::of($v['book']['quantity'])->minus($v['quantity_milli'])),'source_version'=>(int)$v['entry']['version'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']];
|
||||
}
|
||||
private function delivery(array $v,array $b,int $movementId,bool $lock=false,bool $partial=false,bool $returning=false): array
|
||||
{
|
||||
abort_unless(($b['eligible_deliveries'][$movementId]['order_id']??null)===(int)$v['entry']['order_id'],403,'Entrega sem elegibilidade explícita para esta ordem.');
|
||||
$q=DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$movementId,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
||||
$m=$q->first();abort_unless($m,403);
|
||||
$q=DB::table($b['schema'].'.stock_operations')->where(['tenant_id'=>$b['source_tenant_id'],'id'=>$m->stock_operation_id,'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']]);if($lock)$q->lockForUpdate();
|
||||
$o=$q->first();abort_unless($o&&$o->kind==='issue'&&$m->movement_type==='issue'&&$m->equipment_id===null&&$m->spare_part_id===null,409,'Movimento incompatível com entrega simples.');
|
||||
$snapshot=json_decode($m->snapshot,true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($snapshot['inventory_operation_hash']??null)===$this->hash((array)$o),409,'Entrega sem fotografia íntegra do escritor comum; exige conferência histórica própria.');
|
||||
abort_if(DB::table($b['schema'].'.stock_movements')->where(['tenant_id'=>$b['source_tenant_id'],'stock_item_id'=>$v['stock_item_id'],'warehouse_id'=>$v['warehouse_id']])->whereNotIn('movement_type',['receipt_in','issue','workshop_return_in'])->exists(),409,'Livro com estornos, transferências ou ajustes fora deste ensaio simples.');
|
||||
$returns=$this->returnTable($b);
|
||||
$q=DB::table($b['schema'].'.stock_movements AS m')->where(['m.tenant_id'=>$b['source_tenant_id'],'m.stock_item_id'=>$v['stock_item_id'],'m.warehouse_id'=>$v['warehouse_id'],'m.movement_type'=>'workshop_return_in']);
|
||||
if($returns)$q->whereNotExists(fn($x)=>$x->selectRaw('1')->from($returns.' AS r')->whereColumn('r.movement_id','m.id'));
|
||||
abort_if($q->exists(),409,'Entrada de devolução sem decisão rastreável.');
|
||||
$total=BigDecimal::of((string)$m->quantity_milli)->negated();
|
||||
abort_unless($total->isPositive()&&$total->isEqualTo((string)$o->quantity_milli),409,'Quantidade da entrega incompatível com a operação.');
|
||||
$allocations=$this->allocationTable($b);$used=$allocations?(string)DB::table($allocations)->where('movement_id',$movementId)->sum('quantity_milli'):'0';
|
||||
$returned=$returns?(string)DB::table($returns)->where('parent_movement_id',$movementId)->sum('quantity_milli'):'0';$remaining=$total->minus($used)->minus($returned);
|
||||
if($partial||$returning){abort_unless($returning?(($b['stock_returns']??false)&&$returns):(($b['partial_delivery_links']??false)&&$allocations),503,'Tratamento de repartição/devolução não configurado neste ensaio.');abort_unless($remaining->isGreaterThanOrEqualTo($v['quantity_milli']),409,'Quantidade não associada insuficiente. Faça nova conferência.');}
|
||||
else abort_unless($total->isEqualTo($v['quantity_milli'])&&BigDecimal::of($used)->isZero()&&BigDecimal::of($returned)->isZero(),409,'Entrega e consumo devem ter a mesma quantidade integral; use conferência de repartição quando aplicável.');
|
||||
abort_unless(\Carbon\CarbonImmutable::parse($m->moved_at)->lessThanOrEqualTo(\Carbon\CarbonImmutable::parse($v['entry']['starts_at'])),409,'A entrega deve preceder o consumo observado.');
|
||||
abort_if(DB::table($b['schema'].'.ws_claims')->where('movement_id',$movementId)->exists(),409,'Entrega já ligada a outra decisão.');
|
||||
$v['delivery']=['movement'=>(array)$m,'operation'=>(array)$o,'partial'=>$partial,'returning'=>$returning,'allocated_milli'=>$used,'returned_milli'=>$returned,'remaining_milli'=>(string)$remaining];return $v;
|
||||
}
|
||||
public function prepareDelivery(array $d,string $key,bool $partial=false,bool $returning=false): array
|
||||
{
|
||||
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();$request=['mode'=>$returning?'return_existing_delivery':($partial?'allocate_existing_delivery':'link_existing_delivery')]+$d;
|
||||
return DB::transaction(function()use($s,$b,$actor,$d,$key,$request,$partial,$returning){$this->lock($s);$this->scope('prepare');
|
||||
$old=DB::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($request)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
|
||||
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true,true,$returning?'returned':'consumed');
|
||||
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já ligada; exige correcção própria.');
|
||||
$v=$this->delivery($v,$b,$d['movement_id'],true,$partial,$returning);
|
||||
$id=DB::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],'command_key'=>$key,'request_hash'=>$this->hash($request),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||
Audit::record($returning?'workshop_stock.return_prepared':'workshop_stock.delivery_prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version'],'movement_id'=>$d['movement_id'],'physical_effect'=>$returning?(string)$v['quantity_milli']:'0']);
|
||||
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
|
||||
}
|
||||
public function listing(int $page=1,int $proposalPage=1,string $status='all'): array
|
||||
{
|
||||
$s=$this->scope('view');$b=$this->binding($s);
|
||||
$q=DB::table('wo_entries AS e')->join('wo_orders AS o','o.id','=','e.order_id')->join('wo_decisions AS d','d.entry_id','=','e.id')
|
||||
->where('o.tenant_id',$s['tenant_id'])->where('o.company_id',$s['company_id'])->where('o.unit_id',$s['unit_id'])->whereIn('o.id',$b['eligible_order_ids'])
|
||||
->where(['e.kind'=>'material','e.outside_authority'=>false,'d.decision'=>'verified'])->whereRaw("e.data::jsonb->>'owner'='company'")->whereRaw(($b['stock_returns']??false)?"e.data::jsonb->>'event' IN ('consumed','returned')":"e.data::jsonb->>'event'='consumed'")
|
||||
->whereNotExists(fn($x)=>$x->selectRaw('1')->from('wo_entries AS n')->whereColumn('n.previous_id','e.id'))
|
||||
->select(['e.id','e.order_id','e.origin','e.version','e.quantity','e.data'])->orderBy('e.id')->paginate(25,['*'],'page',$page);
|
||||
$proposalQuery=DB::table($b['schema'].'.ws_proposals AS p')->where($s);
|
||||
$closureTable=$this->closureTable($b);
|
||||
if(in_array($status,['withdrawn','rejected'],true)){
|
||||
if($closureTable)$proposalQuery->whereExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id')->where('z.state',$status));
|
||||
else $proposalQuery->whereRaw('false');
|
||||
}
|
||||
if(in_array($status,['pending','confirmed'],true))$proposalQuery->where(function($query)use($b,$status){
|
||||
$tables=array_filter([$b['schema'].'.ws_claims',$this->allocationTable($b),$this->returnTable($b)]);
|
||||
foreach($tables as $i=>$t){$method=$status==='confirmed'?($i===0?'whereExists':'orWhereExists'):'whereNotExists';$query->$method(fn($x)=>$x->selectRaw('1')->from($t.' AS c')->whereColumn('c.proposal_id','p.id'));}
|
||||
});
|
||||
if($status==='pending'&&$closureTable)$proposalQuery->whereNotExists(fn($x)=>$x->selectRaw('1')->from($closureTable.' AS z')->whereColumn('z.proposal_id','p.id'));
|
||||
$proposalPager=$proposalQuery->orderByDesc('p.id')->paginate(25,['p.id','p.entry_id','p.entry_version','p.preparer_id','p.reason','p.evidence','p.snapshot','p.source_hash','p.created_at'],'proposal_page',$proposalPage);$proposals=$proposalPager->getCollection();
|
||||
$keys=array_map(fn($e)=>$this->hash([$s,$e->order_id,$e->origin]),$q->items());$linked=DB::table($b['schema'].'.ws_claims')->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key');
|
||||
if($t=$this->allocationTable($b))$linked=$linked->merge(DB::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
||||
if($t=$this->returnTable($b))$linked=$linked->merge(DB::table($t)->whereIn('origin_key',$keys)->get(['origin_key','movement_id'])->keyBy('origin_key'));
|
||||
foreach($q->items() as $e)$e->movement_id=$linked->get($this->hash([$s,$e->order_id,$e->origin]))?->movement_id;
|
||||
$claims=DB::table($b['schema'].'.ws_claims')->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id'])->keyBy('proposal_id');
|
||||
if($t=$this->allocationTable($b))foreach(DB::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
||||
if($t=$this->returnTable($b))foreach(DB::table($t)->whereIn('proposal_id',$proposals->pluck('id'))->get(['proposal_id','movement_id']) as $a)$claims->put($a->proposal_id,$a);
|
||||
$closed=$closureTable?DB::table($closureTable)->where($s)->whereIn('proposal_id',$proposals->pluck('id'))->get()->keyBy('proposal_id'):collect();
|
||||
foreach($proposals as $p){$p->impact=$this->impact(json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR));unset($p->snapshot);$p->movement_id=$claims->get($p->id)?->movement_id;$p->closure=$closed->get($p->id);$p->state=$p->closure?->state??($p->movement_id?'confirmed':'pending');$p->state_version=$p->state==='pending'?1:2;
|
||||
$p->can_withdraw=$closureTable&&$p->state==='pending'&&(int)$p->preparer_id===$this->context->userId();$p->can_review=$p->state==='pending'&&(int)$p->preparer_id!==$this->context->userId();}
|
||||
return ['sources'=>$q->items(),'pagination'=>['page'=>$q->currentPage(),'last_page'=>$q->lastPage(),'total'=>$q->total()],
|
||||
'proposals'=>$proposals,'closure_supported'=>(bool)$closureTable,'proposal_pagination'=>['page'=>$proposalPager->currentPage(),'last_page'=>$proposalPager->lastPage(),'total'=>$proposalPager->total(),'status'=>$status],'notice'=>'Ensaio local: decisões individuais de saída, ligação, repartição e devolução. Nenhum efeito é automático; correcções físicas de material consumido continuam separadas.'];
|
||||
}
|
||||
public function prepare(array $d,string $key): array
|
||||
{
|
||||
$s=$this->scope('prepare');$b=$this->binding($s);$actor=$this->context->userId();
|
||||
return DB::transaction(function()use($s,$b,$actor,$d,$key){$this->lock($s);$this->scope('prepare');
|
||||
$old=DB::table($b['schema'].'.ws_proposals')->where($s)->where(['preparer_id'=>$actor,'command_key'=>$key])->first();
|
||||
if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave repetida com conteúdo diferente.');return $this->proposalResult($s,$b,$old);}
|
||||
$v=$this->source($s,$b,$d['entry_id'],$d['version'],$d['warehouse_id'],true);
|
||||
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida; correcções exigem compensação própria.');
|
||||
$id=DB::table($b['schema'].'.ws_proposals')->insertGetId($s+['entry_id'=>$d['entry_id'],'entry_version'=>$d['version'],'preparer_id'=>$actor,'reason'=>$d['reason'],'evidence'=>$d['evidence'],
|
||||
'command_key'=>$key,'request_hash'=>$this->hash($d),'source_hash'=>$this->hash($v),'snapshot'=>json_encode($v,JSON_THROW_ON_ERROR),'created_at'=>now()]);
|
||||
Audit::record('workshop_stock.prepared',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'entry_id'=>$d['entry_id'],'version'=>$d['version']]);
|
||||
return ['id'=>$id,'impact'=>$this->impact($v),'reason'=>$d['reason'],'evidence'=>$d['evidence'],'source_hash'=>$this->hash($v),'state'=>'pending','state_version'=>1,'replayed'=>false];});
|
||||
}
|
||||
public function confirm(int $id,string $reason,bool $receivedInWarehouse=false): array
|
||||
{
|
||||
$s=$this->scope('confirm');$b=$this->binding($s);$actor=$this->context->userId();
|
||||
return DB::transaction(function()use($s,$b,$actor,$id,$reason,$receivedInWarehouse){$this->lock($s);$this->scope('confirm');
|
||||
$p=DB::table($b['schema'].'.ws_proposals')->where($s)->where('id',$id)->lockForUpdate()->first();abort_unless($p,403);abort_if((int)$p->preparer_id===$actor,403,'Confirmação deve ser independente da preparação.');
|
||||
abort_if($this->closure($s,$b,$id),409,'Proposta retirada ou recusada. Faça nova conferência; este original permanece encerrado.');
|
||||
$old=$this->proposalClaim($b,$id);if($old)return ['ok'=>true,'replayed'=>true,'movement_id'=>(int)$old->movement_id,'claim_id'=>(int)$old->id];
|
||||
$frozen=json_decode($p->snapshot,true,512,JSON_THROW_ON_ERROR);$link=isset($frozen['delivery']);$returning=$link&&($frozen['delivery']['returning']??false);$v=$this->source($s,$b,(int)$p->entry_id,(int)$p->entry_version,$frozen['warehouse_id'],true,$link,$returning?'returned':'consumed');
|
||||
abort_unless(!$returning||$receivedInWarehouse,422,'Confirme expressamente a recepção física no armazém.');
|
||||
$partial=$link&&($frozen['delivery']['partial']??false);
|
||||
if($link)$v=$this->delivery($v,$b,(int)$frozen['delivery']['movement']['id'],true,$partial,$returning);
|
||||
abort_if($this->originClaimed($b,$v['origin_key']),409,'Origem já consumida por outra decisão.');
|
||||
abort_unless(hash_equals($p->source_hash,$this->hash($v)),409,'Origem, mapa ou livro mudou. Faça nova conferência.');
|
||||
if($link&&!$returning){$operation=(int)$v['delivery']['operation']['id'];$out=['movement_id'=>(int)$v['delivery']['movement']['id']];}
|
||||
else {
|
||||
$operation=DB::table($b['schema'].'.stock_operations')->insertGetId(['tenant_id'=>$b['source_tenant_id'],'created_by'=>$actor,'responsible_id'=>$actor,'warehouse_id'=>$v['warehouse_id'],'stock_item_id'=>$v['stock_item_id'],
|
||||
'kind'=>$returning?'workshop_return':'issue','reference'=>'SGF-WO-'.$v['origin_key'],'idempotency_key'=>(string)Str::uuid(),'payload_hash'=>$p->source_hash,'destination'=>'workshop-origin:'.$v['origin_key'],'reason'=>$reason,
|
||||
'quantity_milli'=>$v['quantity_milli'],'theoretical_milli'=>$v['book']['quantity'],'ledger_version'=>$v['book']['revision'],'recorded_at'=>now(),
|
||||
'snapshot'=>json_encode(($returning?['delivery_movement_id'=>(int)$v['delivery']['movement']['id'],'delivery_movement_hash'=>$this->hash($v['delivery']['movement']),'received_in_warehouse'=>$receivedInWarehouse]:[])+['workshop_origin'=>$v['entry'],'verification'=>$v['decision'],'proposal_id'=>$id,'preparer_id'=>$p->preparer_id,'scope'=>$s,'evidence'=>$p->evidence],JSON_THROW_ON_ERROR)]);
|
||||
$writer=$returning?\Tempo\Inventory\DeliveryReturnWriter::class:\Tempo\Inventory\IssueMovementWriter::class;
|
||||
$w=new $writer(DB::connection(),$b['schema'],$b['source_tenant_id'],$actor,function(){$this->scope('confirm');});$out=$w->write($operation,$w->preview($operation));
|
||||
}
|
||||
$after=$this->impact($v)['physical_after'];
|
||||
$claim=DB::table($returning?$this->returnTable($b):($partial?$this->allocationTable($b):$b['schema'].'.ws_claims'))->insertGetId($s+($returning?['quantity_milli'=>$v['quantity_milli'],'parent_movement_id'=>(int)$v['delivery']['movement']['id']]:($partial?['quantity_milli'=>$v['quantity_milli']]:[]))+['origin_key'=>$v['origin_key'],'proposal_id'=>$id,'entry_id'=>$p->entry_id,'entry_version'=>$p->entry_version,'operation_id'=>$operation,'movement_id'=>$out['movement_id'],
|
||||
'preparer_id'=>$p->preparer_id,'confirmer_id'=>$actor,'reason'=>$reason,'before_milli'=>$v['book']['quantity'],'after_milli'=>$after,'source_hash'=>$p->source_hash,'created_at'=>now()]);
|
||||
Audit::record($returning?'workshop_stock.return_confirmed':($partial?'workshop_stock.delivery_allocated':($link?'workshop_stock.delivery_linked':'workshop_stock.confirmed')),$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['claim_id'=>$claim,'proposal_id'=>$id,'entry_id'=>$p->entry_id,'version'=>$p->entry_version,'movement_id'=>$out['movement_id'],'linked_quantity'=>$link?(string)$v['quantity_milli']:null,'remaining_after'=>($partial||$returning)?$this->impact($v)['unallocated_after']:null,'before'=>$v['book']['quantity'],'after'=>$after,'reason'=>$reason]);
|
||||
return ['ok'=>true,'replayed'=>false,'movement_id'=>$out['movement_id'],'claim_id'=>$claim];});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user