Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	Dockerfile
This commit is contained in:
Chever John 2026-06-23 09:34:08 +08:00
commit df0d490a42
No known key found for this signature in database
GPG Key ID: 2894D52ED1211DF0
51 changed files with 1805 additions and 3583 deletions

View File

@ -10,5 +10,6 @@ RUN npx webpack --mode production
FROM ${BASE_REGISTRY}/nginx:1.27-alpine FROM ${BASE_REGISTRY}/nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3015 EXPOSE 3015
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

42
nginx.conf Normal file
View File

@ -0,0 +1,42 @@
server {
listen 3015;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
# API 反向代理
location /api/ {
proxy_pass http://gateway:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket 反向代理
location /ws/ {
proxy_pass http://gateway:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

View File

@ -20,8 +20,6 @@
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"framer-motion": "^11.16.1", "framer-motion": "^11.16.1",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"@supabase/supabase-js": "^2.98.0",
"base64-arraybuffer": "^1.0.2",
"zustand": "^4.4.7", "zustand": "^4.4.7",
"immer": "^10.0.3" "immer": "^10.0.3"
}, },

52
src/api/auth.ts Normal file
View File

@ -0,0 +1,52 @@
import { http, setAccessToken } from './client';
import type { Profile, Company, UserRole } from '../types';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
user: Profile;
company: Company | null;
}
export interface RegisterRequest {
username: string;
password: string;
display_name: string;
company_name: string;
phone: string;
role: UserRole;
}
export interface MeResponse {
user: Profile;
company: Company | null;
}
export const authApi = {
login: async (data: LoginRequest) => {
const res = await http.post<LoginResponse>('/auth/login', data);
setAccessToken(res.data.access_token);
return res.data;
},
register: (data: RegisterRequest) =>
http.post<LoginResponse>('/auth/register', data).then(r => r.data),
logout: async () => {
await http.post('/auth/logout');
setAccessToken(null);
},
refresh: async () => {
const res = await http.post<{ access_token: string }>('/auth/refresh');
setAccessToken(res.data.access_token);
return res.data.access_token;
},
me: () => http.get<MeResponse>('/auth/me').then(r => r.data),
};

119
src/api/client.ts Normal file
View File

@ -0,0 +1,119 @@
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
meta?: { page: number; page_size: number; total: number; has_more: boolean };
}
export interface PaginationParams {
page?: number;
page_size?: number;
}
const API_BASE = '/api/v1';
let accessToken: string | null = null;
let refreshPromise: Promise<boolean> | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
async function refreshToken(): Promise<boolean> {
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const body: ApiResponse<{ access_token: string }> = await res.json();
if (body.code === 0 && body.data.access_token) {
accessToken = body.data.access_token;
return true;
}
return false;
} catch {
return false;
}
}
async function request<T>(
method: string,
path: string,
body?: unknown,
params?: Record<string, string | number | undefined>,
): Promise<ApiResponse<T>> {
let url = `${API_BASE}${path}`;
if (params) {
const qs = Object.entries(params)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
if (qs) url += `?${qs}`;
}
const headers: Record<string, string> = {};
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
let res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: 'include',
});
if (res.status === 401 && accessToken) {
if (!refreshPromise) refreshPromise = refreshToken();
const ok = await refreshPromise;
refreshPromise = null;
if (ok) {
headers['Authorization'] = `Bearer ${accessToken}`;
res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: 'include',
});
} else {
accessToken = null;
window.dispatchEvent(new CustomEvent('auth:expired'));
}
}
const json: ApiResponse<T> = await res.json();
if (json.code !== 0) {
throw new ApiError(json.code, json.message);
}
return json;
}
export class ApiError extends Error {
constructor(public code: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
export const http = {
get: <T>(path: string, params?: Record<string, string | number | undefined>) =>
request<T>('GET', path, undefined, params),
post: <T>(path: string, body?: unknown) =>
request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) =>
request<T>('PUT', path, body),
patch: <T>(path: string, body?: unknown) =>
request<T>('PATCH', path, body),
delete: <T>(path: string) =>
request<T>('DELETE', path),
};

49
src/api/common.ts Normal file
View File

@ -0,0 +1,49 @@
import { http } from './client';
import type { Company, CompanyMember, Profile } from '../types';
interface Notification {
id: string;
type: string;
title: string;
content: string;
is_read: boolean;
created_at: string;
}
interface CompanyRelationship {
id: string;
company: Company;
role: string;
}
export const commonApi = {
getCompany: (id: string) =>
http.get<Company>(`/common/companies/${id}`).then(r => r.data),
listRelationships: () =>
http.get<CompanyRelationship[]>('/common/companies').then(r => r.data),
listMembers: () =>
http.get<(CompanyMember & { profile: Profile })[]>('/common/members').then(r => r.data),
createMember: (data: { username: string; password: string; display_name: string; phone?: string }) =>
http.post<CompanyMember>('/common/members', data).then(r => r.data),
deleteMember: (id: string) =>
http.delete(`/common/members/${id}`),
listNotifications: () =>
http.get<Notification[]>('/common/notifications').then(r => r.data),
markNotificationRead: (id: string) =>
http.patch(`/common/notifications/${id}`, { is_read: true }),
createShareLink: (data: { type: string; target_id: string }) =>
http.post<{ code: string }>('/common/share-links', data).then(r => r.data),
getShareLink: (code: string) =>
http.get<unknown>(`/common/share-links/${code}`).then(r => r.data),
submitFeedback: (data: { content: string }) =>
http.post('/common/feedback', data),
};

8
src/api/index.ts Normal file
View File

@ -0,0 +1,8 @@
export { http, setAccessToken, getAccessToken, ApiError } from './client';
export type { ApiResponse, PaginationParams } from './client';
export { authApi } from './auth';
export { commonApi } from './common';
export { purchaserApi } from './purchaser';
export { textileApi } from './textile';
export { washingApi } from './washing';
export { wsClient } from './websocket';

155
src/api/purchaser.ts Normal file
View File

@ -0,0 +1,155 @@
import { http } from './client';
import type { PaginationParams, ApiResponse } from './client';
import type {
ProductionPlan,
PlanFactory,
YarnRatio,
PlanProcessStep,
Product,
WashingPlan,
Company,
} from '../types';
import type { ProductPriceHistory, ProductInRecord } from '../types';
// --- Plans ---
export interface PlanDetail extends ProductionPlan {
process_steps: PlanProcessStep[];
inventory_records: { time: string; quantity: number; rolls: number | null }[];
price_history?: ProductPriceHistory[];
yarn_ratios?: YarnRatio[];
image_url?: string | null;
warp_weight?: number | null;
weft_weight?: number | null;
total_weight?: number | null;
}
export interface PlanListResponse {
plans: PlanDetail[];
factories: Company[];
plan_factories: PlanFactory[];
operator_names: Record<string, string>;
}
export interface CreatePlanRequest {
product_name: string;
fabric_code: string;
color_code: string;
color: string;
target_quantity: number;
unit_price?: number;
notes?: string;
yarn_ratios?: { yarn_name: string; ratio: number; amount_per_meter?: number; yarn_type?: string }[];
}
// --- Products ---
export interface InboundRequest {
batch_no: string;
rolls: number;
meters: number;
notes?: string;
warehouse_id?: string;
plan_code?: string;
factory_id?: string;
source?: 'self' | 'textile';
price_per_meter?: number;
}
export interface OutboundRequest {
batch_no: string;
rolls: number;
meters: number;
outbound_type: string;
notes?: string;
washing_plan_id?: string;
recipient_company_id?: string;
}
// --- Dashboard ---
export interface DashboardStats {
total_plans: number;
producing_plans: number;
completed_plans: number;
total_products: number;
total_stock_meters: number;
pending_payable: number;
}
export const purchaserApi = {
// Dashboard
dashboardStats: () =>
http.get<DashboardStats>('/purchaser/dashboard/stats').then(r => r.data),
// Plans
listPlans: (params?: PaginationParams) =>
http.get<PlanListResponse>('/purchaser/plans', params as Record<string, number>),
createPlan: (data: CreatePlanRequest) =>
http.post<ProductionPlan>('/purchaser/plans', data).then(r => r.data),
getPlan: (id: string) =>
http.get<PlanDetail>(`/purchaser/plans/${id}`).then(r => r.data),
updatePlan: (id: string, data: Partial<CreatePlanRequest>) =>
http.put<ProductionPlan>(`/purchaser/plans/${id}`, data).then(r => r.data),
deletePlan: (id: string) =>
http.delete(`/purchaser/plans/${id}`),
assignFactory: (planId: string, data: { factory_id: string; factory_type: string }) =>
http.post<PlanFactory>(`/purchaser/plans/${planId}/factories`, data).then(r => r.data),
getPlanSteps: (planId: string) =>
http.get<PlanProcessStep[]>(`/purchaser/plans/${planId}/steps`).then(r => r.data),
getPlanInventory: (planId: string) =>
http.get<unknown[]>(`/purchaser/plans/${planId}/inventory`).then(r => r.data),
// Products
listProducts: (params?: PaginationParams) =>
http.get<Product[]>('/purchaser/products', params as Record<string, number>),
createProduct: (data: Record<string, unknown>) =>
http.post<Product>('/purchaser/products', data).then(r => r.data),
updateProduct: (id: string, data: Record<string, unknown>) =>
http.put<Product>(`/purchaser/products/${id}`, data).then(r => r.data),
deleteProduct: (id: string) =>
http.delete(`/purchaser/products/${id}`),
inbound: (productId: string, data: InboundRequest) =>
http.post<ProductInRecord>(`/purchaser/products/${productId}/inbound`, data).then(r => r.data),
outbound: (productId: string, data: OutboundRequest) =>
http.post<ProductInRecord>(`/purchaser/products/${productId}/outbound`, data).then(r => r.data),
getRecords: (productId: string) =>
http.get<ProductInRecord[]>(`/purchaser/products/${productId}/records`).then(r => r.data),
updatePrice: (productId: string, data: { price: number; reason?: string }) =>
http.put(`/purchaser/products/${productId}/price`, data),
priceHistory: (productId: string) =>
http.get<ProductPriceHistory[]>(`/purchaser/products/${productId}/price-history`).then(r => r.data),
// Factories
listFactories: () =>
http.get<Company[]>('/purchaser/factories').then(r => r.data),
// Accounts Payable
listAccountsPayable: (params?: PaginationParams) =>
http.get<unknown[]>('/purchaser/accounts-payable', params as Record<string, number>),
payAccountsPayable: (id: string, data: { amount: number; notes?: string }) =>
http.post(`/purchaser/accounts-payable/${id}/pay`, data),
// Washing Plans
createWashingPlan: (data: Partial<WashingPlan>) =>
http.post<WashingPlan>('/purchaser/washing-plans', data).then(r => r.data),
listWashingPlans: (params?: PaginationParams) =>
http.get<WashingPlan[]>('/purchaser/washing-plans', params as Record<string, number>),
};

74
src/api/textile.ts Normal file
View File

@ -0,0 +1,74 @@
import { http } from './client';
import type { PaginationParams } from './client';
import type {
ProductionPlan,
PlanProcessStep,
YarnStock,
InventoryRecord,
Payment,
} from '../types';
// --- Plans ---
export interface TextilePlanDetail extends ProductionPlan {
process_steps: PlanProcessStep[];
purchaser_name: string;
}
// --- Dashboard ---
export interface TextileDashboardStats {
total_plans: number;
producing_plans: number;
completed_plans: number;
total_yarn_types: number;
total_inventory_meters: number;
pending_receivable: number;
}
export const textileApi = {
// Dashboard
dashboardStats: () =>
http.get<TextileDashboardStats>('/textile/dashboard/stats').then(r => r.data),
// Plans (read-only from textile perspective)
listPlans: (params?: PaginationParams) =>
http.get<TextilePlanDetail[]>('/textile/plans', params as Record<string, number>),
getPlan: (id: string) =>
http.get<TextilePlanDetail>(`/textile/plans/${id}`).then(r => r.data),
// Process Steps
completeStep: (planId: string, stepId: string, data?: { notes?: string; operator_name?: string }) =>
http.post<PlanProcessStep>(`/textile/plans/${planId}/steps/${stepId}/complete`, data).then(r => r.data),
rejectStep: (planId: string, stepId: string, data: { reason: string }) =>
http.post<PlanProcessStep>(`/textile/plans/${planId}/steps/${stepId}/reject`, data).then(r => r.data),
// Inventory
inbound: (planId: string, data: { quantity: number; rolls?: number; notes?: string }) =>
http.post<InventoryRecord>(`/textile/plans/${planId}/inventory`, data).then(r => r.data),
listInventory: (planId: string) =>
http.get<InventoryRecord[]>(`/textile/plans/${planId}/inventory`).then(r => r.data),
// Yarn Stock
listYarnStock: (params?: PaginationParams) =>
http.get<YarnStock[]>('/textile/yarn-stock', params as Record<string, number>),
createYarnStock: (data: Partial<YarnStock>) =>
http.post<YarnStock>('/textile/yarn-stock', data).then(r => r.data),
updateYarnStock: (id: string, data: Partial<YarnStock>) =>
http.put<YarnStock>(`/textile/yarn-stock/${id}`, data).then(r => r.data),
addYarnRecord: (id: string, data: { change_type: string; quantity: number; notes?: string }) =>
http.post(`/textile/yarn-stock/${id}/records`, data),
// Payments
listPayments: (params?: PaginationParams) =>
http.get<Payment[]>('/textile/payments', params as Record<string, number>),
confirmPayment: (id: string) =>
http.post<Payment>(`/textile/payments/${id}/confirm`).then(r => r.data),
};

63
src/api/washing.ts Normal file
View File

@ -0,0 +1,63 @@
import { http } from './client';
import type { PaginationParams } from './client';
import type {
WashingPlan,
FinishedProduct,
FinishedProductInventoryRecord,
Payment,
} from '../types';
import type { WashingCompletionFormData } from '../types';
// --- Dashboard ---
export interface WashingDashboardStats {
total_plans: number;
processing_plans: number;
completed_plans: number;
total_finished_products: number;
total_stock_meters: number;
pending_receivable: number;
}
export const washingApi = {
// Dashboard
dashboardStats: () =>
http.get<WashingDashboardStats>('/washing/dashboard/stats').then(r => r.data),
// Plans
listPlans: (params?: PaginationParams) =>
http.get<WashingPlan[]>('/washing/plans', params as Record<string, number>),
listPendingPlans: () =>
http.get<WashingPlan[]>('/washing/plans/pending').then(r => r.data),
listCompletedPlans: () =>
http.get<WashingPlan[]>('/washing/plans/completed').then(r => r.data),
getPlan: (id: string) =>
http.get<WashingPlan>(`/washing/plans/${id}`).then(r => r.data),
updatePlanStatus: (id: string, data: { status: string }) =>
http.patch(`/washing/plans/${id}/status`, data),
// Completions
completePlan: (planId: string, data: WashingCompletionFormData) =>
http.post(`/washing/plans/${planId}/complete`, data),
// Finished Products
listFinishedProducts: (params?: PaginationParams) =>
http.get<FinishedProduct[]>('/washing/finished-products', params as Record<string, number>),
createFinishedProduct: (data: Partial<FinishedProduct>) =>
http.post<FinishedProduct>('/washing/finished-products', data).then(r => r.data),
outboundFinishedProduct: (id: string, data: { rolls: number; meters: number; notes?: string }) =>
http.post<FinishedProductInventoryRecord>(`/washing/finished-products/${id}/outbound`, data).then(r => r.data),
// Payments
listPayments: (params?: PaginationParams) =>
http.get<Payment[]>('/washing/payments', params as Record<string, number>),
confirmPayment: (id: string) =>
http.post<Payment>(`/washing/payments/${id}/confirm`).then(r => r.data),
};

89
src/api/websocket.ts Normal file
View File

@ -0,0 +1,89 @@
type ChangeCallback = () => void;
interface WSEvent {
table: string;
action: 'INSERT' | 'UPDATE' | 'DELETE';
company_id: string;
}
type WSRole = 'purchaser' | 'textile' | 'washing';
class WebSocketClient {
private ws: WebSocket | null = null;
private listeners = new Map<string, Set<ChangeCallback>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private role: WSRole | null = null;
private connected = false;
connect(role: WSRole) {
this.role = role;
this.doConnect();
}
disconnect() {
this.connected = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
subscribe(table: string, callback: ChangeCallback): () => void {
if (!this.listeners.has(table)) {
this.listeners.set(table, new Set());
}
this.listeners.get(table)!.add(callback);
return () => {
this.listeners.get(table)?.delete(callback);
if (this.listeners.get(table)?.size === 0) {
this.listeners.delete(table);
}
};
}
private doConnect() {
if (!this.role) return;
this.connected = true;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${location.host}/ws/v1/${this.role}`;
try {
this.ws = new WebSocket(url);
this.ws.onmessage = (e) => {
try {
const event: WSEvent = JSON.parse(e.data);
this.listeners.get(event.table)?.forEach(cb => cb());
this.listeners.get('*')?.forEach(cb => cb());
} catch {
// ignore malformed messages
}
};
this.ws.onclose = () => {
if (this.connected) this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
} catch {
this.scheduleReconnect();
}
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (this.connected) this.doConnect();
}, 3000);
}
}
export const wsClient = new WebSocketClient();

View File

@ -14,7 +14,7 @@ import {
Droplets Droplets
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase/client'; import { http } from '../api';
export type AccountRole = 'purchaser' | 'textile' | 'washing'; export type AccountRole = 'purchaser' | 'textile' | 'washing';
@ -79,34 +79,30 @@ export function AccountProfileCard({
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const { data: { user } } = await supabase.auth.getUser(); if (!auth.user) return;
if (!user) return;
try { try {
const fileExt = file.name.split('.').pop(); // TODO: 头像上传接口待后端实现
const fileName = `${user.id}-${Date.now()}.${fileExt}`; const formDataObj = new FormData();
const arrayBuffer = await file.arrayBuffer(); formDataObj.append('file', file);
formDataObj.append('bucket', 'avatars');
const { error: uploadError } = await supabase.storage const res = await fetch('/api/v1/common/upload', {
.from('avatars') method: 'POST',
.upload(fileName, arrayBuffer, { contentType: file.type, upsert: true }); body: formDataObj,
});
if (uploadError) { if (!res.ok) {
alert('头像上传失败: ' + uploadError.message); alert('头像上传失败');
return; return;
} }
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(fileName); const json = await res.json();
const { error: updateError } = await supabase const publicUrl = json.data?.url || '';
.from('profiles')
.update({ image_url: publicUrl } as any)
.eq('id', user.id);
if (updateError) { await http.patch('/auth/me', { image_url: publicUrl });
alert('头像更新失败: ' + updateError.message);
} else { window.location.reload();
window.location.reload();
}
} catch (err) { } catch (err) {
alert('头像上传出错,请重试'); alert('头像上传出错,请重试');
} }

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react'; import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react';
import { supabase } from '../supabase/client'; import { http } from '../api';
interface CrashReport { interface CrashReport {
error: Error; error: Error;
@ -40,19 +40,13 @@ export function CrashReporter() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// 获取当前用户信息 await http.post('/common/crash-reports', {
const { data: { user } } = await supabase.auth.getUser();
// 提交崩溃报告
await supabase.from('crash_reports').insert({
error_message: crash.error.message, error_message: crash.error.message,
error_stack: crash.error.stack, error_stack: crash.error.stack,
component_stack: crash.errorInfo.componentStack, component_stack: crash.errorInfo.componentStack,
user_description: userDescription.trim() || null, user_description: userDescription.trim() || null,
user_agent: crash.userAgent, user_agent: crash.userAgent,
url: crash.url, url: crash.url,
user_id: user?.id || null,
status: 'pending',
}); });
setIsSubmitted(true); setIsSubmitted(true);

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, Send, MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react'; import { X, Send, MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react';
import { supabase } from '../supabase/client'; import { commonApi } from '../api';
interface FeedbackModalProps { interface FeedbackModalProps {
isOpen: boolean; isOpen: boolean;
@ -29,16 +29,8 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// 获取当前用户信息 await commonApi.submitFeedback({
const { data: { user } } = await supabase.auth.getUser(); content: `[${type}] ${content.trim()}${contact.trim() ? ` (联系方式: ${contact.trim()})` : ''}`,
// 提交反馈到数据库
await supabase.from('user_feedback').insert({
type,
content: content.trim(),
contact: contact.trim() || null,
user_id: user?.id || null,
status: 'pending',
}); });
setIsSuccess(true); setIsSuccess(true);

View File

@ -1,8 +1,7 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Camera, X, Upload, ImageIcon } from 'lucide-react'; import { Camera, X, Upload, ImageIcon } from 'lucide-react';
import { supabase } from '../supabase/client'; import { http } from '../api';
import { decode } from 'base64-arraybuffer';
export type UploadBucket = 'avatars' | 'product_images'; export type UploadBucket = 'avatars' | 'product_images';
@ -69,44 +68,29 @@ export function ImageUploader({
setUploading(true); setUploading(true);
try { try {
// 读取文件为 base64 const formDataObj = new FormData();
const reader = new FileReader(); formDataObj.append('file', file);
reader.onload = async (event) => { formDataObj.append('bucket', bucket);
const base64 = (event.target?.result as string).split(',')[1]; formDataObj.append('path_prefix', pathPrefix);
// 生成唯一文件名 // TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData
const fileExt = file.name.split('.').pop(); const res = await fetch('/api/v1/common/upload', {
const fileName = bucket === 'avatars' method: 'POST',
? `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}` body: formDataObj,
: `${pathPrefix}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`; });
// 上传图片到 Supabase Storage if (!res.ok) {
const { error } = await supabase.storage alert('图片上传失败');
.from(bucket)
.upload(fileName, decode(base64), {
contentType: file.type,
upsert: true
});
if (error) {
console.error('上传失败:', error);
alert('图片上传失败: ' + error.message);
setUploading(false);
return;
}
// 获取图片的公开 URL
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(fileName);
setPreviewUrl(publicUrl);
onChange(publicUrl);
setUploading(false); setUploading(false);
}; return;
reader.readAsDataURL(file); }
const json = await res.json();
const publicUrl = json.data?.url || '';
setPreviewUrl(publicUrl);
onChange(publicUrl);
setUploading(false);
} catch (error) { } catch (error) {
console.error('上传错误:', error);
alert('图片上传失败'); alert('图片上传失败');
setUploading(false); setUploading(false);
} }

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react'; import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { supabase } from '../supabase/client'; import { commonApi } from '../api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
interface Notification { interface Notification {
@ -52,23 +52,15 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
useEffect(() => { useEffect(() => {
if (!auth.user) return; if (!auth.user) return;
fetchNotifications(); fetchNotifications();
const channel = subscribeToNotifications();
return () => { const interval = setInterval(fetchNotifications, 30000);
channel.unsubscribe(); return () => clearInterval(interval);
};
}, [auth.user]); }, [auth.user]);
const fetchNotifications = async () => { const fetchNotifications = async () => {
const { data } = await supabase try {
.from('notifications') const data = await commonApi.listNotifications();
.select('*, sender_company_id(name)') const formatted = (data as any[]).map((n: any) => ({
.eq('recipient_id', auth.user!.id)
.order('created_at', { ascending: false })
.limit(20);
if (data) {
const formatted = data.map(n => ({
id: n.id, id: n.id,
type: n.type as Notification['type'], type: n.type as Notification['type'],
title: n.title, title: n.title,
@ -76,52 +68,27 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
is_read: n.is_read || false, is_read: n.is_read || false,
created_at: n.created_at || '', created_at: n.created_at || '',
plan_id: n.plan_id, plan_id: n.plan_id,
sender_company_name: n.sender_company_id?.name sender_company_name: n.sender_company_name
})); }));
setNotifications(formatted); setNotifications(formatted);
setUnreadCount(formatted.filter(n => !n.is_read).length); setUnreadCount(formatted.filter(n => !n.is_read).length);
} catch {
// ignore
} }
}; };
const subscribeToNotifications = () => {
const channel = supabase.channel('notifications');
channel
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${auth.user!.id}`
}, () => {
fetchNotifications();
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('Successfully subscribed to notifications');
}
});
return channel;
};
const markAsRead = async (id: string) => { const markAsRead = async (id: string) => {
await supabase await commonApi.markNotificationRead(id);
.from('notifications')
.update({ is_read: true, read_at: new Date().toISOString() })
.eq('id', id);
setNotifications(prev => prev.map(n => setNotifications(prev => prev.map(n =>
n.id === id ? { ...n, is_read: true } : n n.id === id ? { ...n, is_read: true } : n
)); ));
setUnreadCount(prev => Math.max(0, prev - 1)); setUnreadCount(prev => Math.max(0, prev - 1));
}; };
const markAllAsRead = async () => { const markAllAsRead = async () => {
await supabase const unread = notifications.filter(n => !n.is_read);
.from('notifications') await Promise.all(unread.map(n => commonApi.markNotificationRead(n.id)));
.update({ is_read: true, read_at: new Date().toISOString() })
.eq('recipient_id', auth.user!.id)
.eq('is_read', false);
setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0); setUnreadCount(0);

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, AlertTriangle, Package, Check, Loader2, Edit3, Search } from 'lucide-react'; import { X, AlertTriangle, Package, Check, Loader2, Edit3, Search } from 'lucide-react';
import { supabase } from '../supabase/client'; import { http } from '../api';
import type { YarnRatio, YarnStock } from '../types'; import type { YarnRatio, YarnStock } from '../types';
interface YarnAllocation { interface YarnAllocation {
@ -54,13 +54,12 @@ export function YarnAllocationModal({
const fetchYarnStock = async () => { const fetchYarnStock = async () => {
setLoading(true); setLoading(true);
const { data } = await supabase try {
.from('yarn_stock') const res = await http.get<YarnStock[]>('/textile/yarn-stock', { company_id: companyId });
.select('*') setYarnStock(res.data || []);
.eq('company_id', companyId) } catch {
.order('name'); setYarnStock([]);
}
setYarnStock(data || []);
setLoading(false); setLoading(false);
}; };
@ -186,40 +185,24 @@ export function YarnAllocationModal({
setConfirming(true); setConfirming(true);
try { try {
// 获取当前用户ID
const { data: { user } } = await supabase.auth.getUser();
const operatorId = user?.id;
if (isManualMode) { if (isManualMode) {
// 手动模式:在仓库中查找或创建纱线并扣除库存
for (const alloc of allocations) { for (const alloc of allocations) {
const manualName = alloc.manualName!; const manualName = alloc.manualName!;
const manualQty = alloc.manualQuantity!; const manualQty = alloc.manualQuantity!;
// 查找是否已有同名纱线
const existingYarn = yarnStock.find(y => const existingYarn = yarnStock.find(y =>
y.name.toLowerCase() === manualName.toLowerCase() y.name.toLowerCase() === manualName.toLowerCase()
); );
if (existingYarn) { if (existingYarn) {
// 扣除现有纱线库存
if (existingYarn.quantity >= manualQty) { if (existingYarn.quantity >= manualQty) {
const newQuantity = existingYarn.quantity - manualQty; const newQuantity = existingYarn.quantity - manualQty;
await supabase await http.put(`/textile/yarn-stock/${existingYarn.id}`, { quantity: newQuantity });
.from('yarn_stock')
.update({ quantity: newQuantity })
.eq('id', existingYarn.id);
// 记录出库 await http.post(`/textile/yarn-stock/${existingYarn.id}/records`, {
await supabase.from('yarn_stock_records').insert({ change_type: 'outbound',
company_id: companyId,
yarn_stock_id: existingYarn.id,
plan_id: planId || null,
record_type: 'outbound',
quantity: manualQty, quantity: manualQty,
unit: 'kg',
notes: `采纱出库 - 计划: ${planId || '未知'}`, notes: `采纱出库 - 计划: ${planId || '未知'}`,
operator_id: operatorId
}); });
} else { } else {
alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg库存 ${existingYarn.quantity}kg`); alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg库存 ${existingYarn.quantity}kg`);
@ -227,54 +210,34 @@ export function YarnAllocationModal({
return; return;
} }
} else { } else {
// 创建新纱线记录并扣除(负库存表示已使用但未入库) const res = await http.post<YarnStock>('/textile/yarn-stock', {
const { data: newYarn } = await supabase company_id: companyId,
.from('yarn_stock') name: manualName,
.insert({ quantity: -manualQty,
company_id: companyId, unit: 'kg',
name: manualName, min_stock: 0
quantity: -manualQty, });
unit: 'kg', const newYarn = res.data;
min_stock: 0
})
.select()
.single();
// 记录出库
if (newYarn) { if (newYarn) {
await supabase.from('yarn_stock_records').insert({ await http.post(`/textile/yarn-stock/${newYarn.id}/records`, {
company_id: companyId, change_type: 'outbound',
yarn_stock_id: newYarn.id,
plan_id: planId || null,
record_type: 'outbound',
quantity: manualQty, quantity: manualQty,
unit: 'kg',
notes: `采纱出库(新纱线) - 计划: ${planId || '未知'}`, notes: `采纱出库(新纱线) - 计划: ${planId || '未知'}`,
operator_id: operatorId
}); });
} }
} }
} }
} else { } else {
// 自动模式:扣除库存
for (const alloc of allocations) { for (const alloc of allocations) {
if (alloc.allocatedYarn && alloc.isSufficient) { if (alloc.allocatedYarn && alloc.isSufficient) {
const newQuantity = alloc.allocatedYarn.quantity - alloc.requiredQuantity; const newQuantity = alloc.allocatedYarn.quantity - alloc.requiredQuantity;
await supabase await http.put(`/textile/yarn-stock/${alloc.allocatedYarn.id}`, { quantity: newQuantity });
.from('yarn_stock')
.update({ quantity: newQuantity })
.eq('id', alloc.allocatedYarn.id);
// 记录出库 await http.post(`/textile/yarn-stock/${alloc.allocatedYarn.id}/records`, {
await supabase.from('yarn_stock_records').insert({ change_type: 'outbound',
company_id: companyId,
yarn_stock_id: alloc.allocatedYarn.id,
plan_id: planId || null,
record_type: 'outbound',
quantity: alloc.requiredQuantity, quantity: alloc.requiredQuantity,
unit: 'kg',
notes: `采纱出库 - 计划: ${planId || '未知'}`, notes: `采纱出库 - 计划: ${planId || '未知'}`,
operator_id: operatorId
}); });
} }
} }

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, AlertTriangle, Edit2, Trash2 } from 'lucide-react'; import { X, AlertTriangle, Edit2, Trash2 } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { supabase } from '../../supabase/client'; import { purchaserApi } from '../../api';
import type { PlanWithSteps } from '../../types'; import type { PlanWithSteps } from '../../types';
interface PlanEditModalProps { interface PlanEditModalProps {
@ -39,24 +39,20 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit
setIsSubmitting(true); setIsSubmitting(true);
setError(''); setError('');
const { error: updateError } = await supabase try {
.from('production_plans') await purchaserApi.updatePlan(plan.id, {
.update({
product_name: editForm.product_name, product_name: editForm.product_name,
color: editForm.color, color_code: editForm.color,
target_quantity: editForm.target_quantity, target_quantity: editForm.target_quantity,
remark: editForm.remark || null, notes: editForm.remark || undefined,
updated_at: new Date().toISOString() });
})
.eq('id', plan.id);
setIsSubmitting(false); setIsSubmitting(false);
if (updateError) {
setError('更新失败: ' + updateError.message);
} else {
onPlanUpdated?.(); onPlanUpdated?.();
onClose(); onClose();
} catch (err: any) {
setIsSubmitting(false);
setError('更新失败: ' + (err.message || '未知错误'));
} }
}; };
@ -69,25 +65,15 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit
setIsSubmitting(true); setIsSubmitting(true);
setError(''); setError('');
// 先删除关联的 process_steps try {
await supabase.from('plan_process_steps').delete().eq('plan_id', plan.id); await purchaserApi.deletePlan(plan.id);
// 再删除 plan_factories 关联
await supabase.from('plan_factories').delete().eq('plan_id', plan.id);
// 最后删除计划
const { error: deleteError } = await supabase
.from('production_plans')
.delete()
.eq('id', plan.id);
setIsSubmitting(false); setIsSubmitting(false);
if (deleteError) {
setError('删除失败: ' + deleteError.message);
} else {
onPlanUpdated?.(); onPlanUpdated?.();
onClose(); onClose();
} catch (err: any) {
setIsSubmitting(false);
setError('删除失败: ' + (err.message || '未知错误'));
} }
}; };

View File

@ -3,7 +3,8 @@ import { motion } from 'framer-motion';
import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react'; import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { encryptShareParams } from '../../utils/shareCrypto'; import { encryptShareParams } from '../../utils/shareCrypto';
import { supabase } from '../../supabase/client'; import { http, commonApi } from '../../api';
import { useAuth } from '../../contexts/AuthContext';
interface ShareModalProps { interface ShareModalProps {
isOpen: boolean; isOpen: boolean;
@ -53,6 +54,7 @@ export function ShareModal({
onDirectPush onDirectPush
}: ShareModalProps) { }: ShareModalProps) {
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const { auth } = useAuth();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [recipientCompany, setRecipientCompany] = useState<string>(''); const [recipientCompany, setRecipientCompany] = useState<string>('');
@ -74,74 +76,51 @@ export function ShareModal({
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!isOpen || !factoryId) return; if (!isOpen || !factoryId) return;
setIsCheckingRelationship(true); setIsCheckingRelationship(true);
const { data: { user } } = await supabase.auth.getUser(); if (!auth.user) {
if (!user) {
setIsCheckingRelationship(false); setIsCheckingRelationship(false);
return; return;
} }
// 获取当前用户的公司信息 try {
const { data: profile } = await supabase if (auth.company) {
.from('profiles') setRecipientCompany(auth.company.name);
.select('company_id')
.eq('id', user.id)
.single();
if (profile?.company_id) { const res = await http.get<{ has_relationship: boolean }>('/common/companies/check-relationship', {
// 获取公司名称 factory_id: factoryId,
const { data: company } = await supabase });
.from('companies') setHasRelationship(res.data.has_relationship);
.select('name')
.eq('id', profile.company_id)
.single();
if (company) {
setRecipientCompany(company.name);
}
// 检查是否已有合作关系 if (!res.data.has_relationship) {
const { data: relationship } = await supabase try {
.from('company_relationships') const linkRes = await http.get<ShareLinkStatus | null>('/common/share-links/active', {
.select('*') plan_id: planId,
.eq('purchaser_company_id', profile.company_id) factory_type: factoryType,
.eq('factory_company_id', factoryId) });
.eq('status', 'active') if (linkRes.data) {
.maybeSingle(); const link = linkRes.data;
const expiresAt = link.expires_at ? new Date(link.expires_at) : null;
setHasRelationship(!!relationship); if (expiresAt && expiresAt > new Date()) {
setExistingLink(link);
// 如果没有合作关系,检查是否已有待处理的分享链接 setShortCode(link.short_code);
if (!relationship) { }
const { data: link } = await supabase }
.from('share_links') } catch {
.select('*') // no existing link
.eq('plan_id', planId)
.eq('factory_type', factoryType)
.in('status', ['pending', 'clicked'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (link) {
// 检查是否过期
const expiresAt = link.expires_at ? new Date(link.expires_at) : null;
if (expiresAt && expiresAt > new Date()) {
setExistingLink(link as ShareLinkStatus);
setShortCode(link.short_code);
} }
} }
} }
} catch {
setHasRelationship(false);
} }
setIsCheckingRelationship(false); setIsCheckingRelationship(false);
}; };
fetchData(); fetchData();
// 清理
return () => { return () => {
shortLinkGenerated.current = false; shortLinkGenerated.current = false;
setShortCode(''); setShortCode('');
@ -154,70 +133,47 @@ export function ShareModal({
// 生成短链30分钟有效 // 生成短链30分钟有效
const generateShortLink = useCallback(async () => { const generateShortLink = useCallback(async () => {
if (!factoryId) return; if (!factoryId) return;
setIsGeneratingLink(true); setIsGeneratingLink(true);
const baseUrl = window.location.origin + window.location.pathname;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
const { data: { user } } = await supabase.auth.getUser(); try {
if (user) { const res = await commonApi.createShareLink({
// 取消该计划之前未处理的链接 type: factoryType,
await supabase target_id: planId,
.from('share_links') });
.update({ status: 'cancelled' })
.eq('plan_id', planId)
.eq('status', 'pending');
// 创建新链接30分钟过期 if (res.code) {
const { data, error } = await supabase.from('share_links').insert({ setShortCode(res.code);
short_code: code,
plan_id: planId,
factory_type: factoryType,
hide_sensitive: hideSensitive,
created_by: user.id,
status: 'pending',
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟
}).select().single();
if (!error && data) {
setShortCode(code);
setExistingLink(data as ShareLinkStatus);
} else { } else {
setError('生成链接失败,请重试'); setError('生成链接失败,请重试');
} }
} catch {
setError('生成链接失败,请重试');
} }
setIsGeneratingLink(false); setIsGeneratingLink(false);
}, [planId, factoryType, hideSensitive, factoryId]); }, [planId, factoryType, hideSensitive, factoryId]);
// 直接推送计划(已有合作关系)
const handleDirectPush = async () => { const handleDirectPush = async () => {
if (!factoryId || !onDirectPush) return; if (!factoryId || !onDirectPush) return;
setIsGeneratingLink(true);
// 直接创建 plan_factories 关联
const { error } = await supabase.from('plan_factories').insert({
plan_id: planId,
factory_id: factoryId,
factory_type: factoryType
});
if (!error) { setIsGeneratingLink(true);
try {
await http.post(`/purchaser/plans/${planId}/factories`, {
factory_id: factoryId,
factory_type: factoryType,
});
setPushSuccess(true); setPushSuccess(true);
setTimeout(() => { setTimeout(() => {
onDirectPush(); onDirectPush();
onClose(); onClose();
}, 1500); }, 1500);
} else { } catch {
setError('推送失败,请重试'); setError('推送失败,请重试');
} }
setIsGeneratingLink(false); setIsGeneratingLink(false);
}; };
@ -265,15 +221,14 @@ export function ShareModal({
// 检查链接状态 // 检查链接状态
const checkLinkStatus = async () => { const checkLinkStatus = async () => {
if (!existingLink) return; if (!existingLink) return;
const { data } = await supabase try {
.from('share_links') const data = await commonApi.getShareLink(existingLink.short_code || existingLink.id);
.select('*') if (data) {
.eq('id', existingLink.id) setExistingLink(data as ShareLinkStatus);
.single(); }
} catch {
if (data) { // ignore
setExistingLink(data as ShareLinkStatus);
} }
}; };

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, Package, AlertCircle, Building2, Tag } from 'lucide-react'; import { X, Package, AlertCircle, Building2, Tag } from 'lucide-react';
import { supabase } from '../../supabase/client'; import { http, commonApi } from '../../api';
import { purchaserApi } from '../../api';
import type { ProductWithInventory, ProductInRecord, Company } from '../../types'; import type { ProductWithInventory, ProductInRecord, Company } from '../../types';
interface OutboundFormProps { interface OutboundFormProps {
@ -49,26 +50,21 @@ export function OutboundForm({
const fetchInboundBatches = async () => { const fetchInboundBatches = async () => {
if (!formData.product_id) return; if (!formData.product_id) return;
setLoadingBatches(true); setLoadingBatches(true);
const { data } = await supabase try {
.from('product_inventory_records') const data = await purchaserApi.getRecords(formData.product_id);
.select('*') setInboundBatches(data || []);
.eq('product_id', formData.product_id) } catch {
.order('created_at', { ascending: false }); setInboundBatches([]);
if (data) {
setInboundBatches(data);
} }
setLoadingBatches(false); setLoadingBatches(false);
}; };
const fetchCompanies = async () => { const fetchCompanies = async () => {
const { data } = await supabase try {
.from('companies') const data = await purchaserApi.listFactories();
.select('*') setCompanies(data || []);
.order('name', { ascending: true }); } catch {
setCompanies([]);
if (data) {
setCompanies(data);
} }
}; };

View File

@ -2,8 +2,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react'; import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { supabase } from '../../supabase/client'; import { http } from '../../api';
import { decode } from 'base64-arraybuffer';
import type { Product, ProductYarnRatio } from '../../types'; import type { Product, ProductYarnRatio } from '../../types';
interface ProductFormData { interface ProductFormData {
@ -138,22 +137,15 @@ export function ProductForm({
} }
setIsSearching(true); setIsSearching(true);
setShowProductSuggestions(true); // 立即显示建议框(带加载状态) setShowProductSuggestions(true);
try { try {
const { data, error } = await supabase const res = await http.get<ProductSuggestion[]>('/purchaser/products', {
.from('products') search: query,
.select('id, product_name, total_weight, warp_weight, weft_weight, color, fabric_code, color_code, production_price, image_url') page_size: 10,
.eq('company_id', companyId) });
.ilike('product_name', `%${query}%`)
.limit(10);
if (!error && data) { setProductSuggestions(res.data || []);
setProductSuggestions(data as ProductSuggestion[]); } catch {
} else {
setProductSuggestions([]);
}
} catch (err) {
console.error('搜索产品失败:', err);
setProductSuggestions([]); setProductSuggestions([]);
} finally { } finally {
setIsSearching(false); setIsSearching(false);
@ -168,22 +160,18 @@ export function ProductForm({
} }
try { try {
const { data, error } = await supabase const res = await http.get<{ total_weight: number | null }[]>('/purchaser/products', {
.from('products') search: productName,
.select('total_weight') page_size: 20,
.eq('company_id', companyId) });
.eq('product_name', productName)
.not('total_weight', 'is', null)
.limit(20);
if (!error && data) { const weights = (res.data || [])
// 提取唯一的克重值,过滤掉 null 值 .map(p => p.total_weight)
const weights = data.map(p => p.total_weight).filter((w): w is number => w !== null && w > 0); .filter((w): w is number => w !== null && w > 0);
const uniqueWeights = Array.from(new Set(weights)); const uniqueWeights = Array.from(new Set(weights));
setWeightSuggestions(uniqueWeights.sort((a, b) => a - b)); setWeightSuggestions(uniqueWeights.sort((a, b) => a - b));
} } catch {
} catch (err) { setWeightSuggestions([]);
console.error('搜索克重失败:', err);
} }
}, [companyId]); }, [companyId]);
@ -214,19 +202,16 @@ export function ProductForm({
if (!prefix) return ''; if (!prefix) return '';
try { try {
// 查询该公司所有以该前缀开头的产品码 const res = await http.get<{ fabric_code: string }[]>('/purchaser/products', {
const { data, error } = await supabase search: prefix,
.from('products') page_size: 100,
.select('fabric_code') });
.eq('company_id', companyId) const data = res.data || [];
.ilike('fabric_code', `${prefix}%`);
if (error || !data || data.length === 0) { if (data.length === 0) {
// 如果没有匹配的产品从1开始
return `${prefix}1`; return `${prefix}1`;
} }
// 提取所有产品码中的数字,找出最大值
let maxNum = 0; let maxNum = 0;
data.forEach(item => { data.forEach(item => {
const code = item.fabric_code; const code = item.fabric_code;
@ -239,10 +224,8 @@ export function ProductForm({
} }
}); });
// 返回前缀 + (最大数字 + 1)
return `${prefix}${maxNum + 1}`; return `${prefix}${maxNum + 1}`;
} catch (err) { } catch {
console.error('生成产品码失败:', err);
return `${prefix}1`; return `${prefix}1`;
} }
}; };
@ -254,20 +237,16 @@ export function ProductForm({
totalWeight: number | null totalWeight: number | null
): Promise<string> => { ): Promise<string> => {
try { try {
// 查询该产品名称-克重组合下的所有色号(色号在产品名称-克重下连续递增) const res = await http.get<{ color_code: string }[]>('/purchaser/products', {
const { data, error } = await supabase search: productName,
.from('products') page_size: 100,
.select('color_code') });
.eq('company_id', companyId) const data = res.data || [];
.eq('product_name', productName)
.eq('total_weight', totalWeight || 0);
if (error || !data || data.length === 0) { if (data.length === 0) {
// 如果没有匹配的产品从01开始
return '01'; return '01';
} }
// 提取所有色号中的数字,找出最大值
let maxNum = 0; let maxNum = 0;
data.forEach(item => { data.forEach(item => {
const code = item.color_code; const code = item.color_code;
@ -277,11 +256,9 @@ export function ProductForm({
} }
}); });
// 返回最大数字 + 1保持两位数格式
const nextNum = maxNum + 1; const nextNum = maxNum + 1;
return nextNum < 10 ? `0${nextNum}` : String(nextNum); return nextNum < 10 ? `0${nextNum}` : String(nextNum);
} catch (err) { } catch {
console.error('生成色号失败:', err);
return '01'; return '01';
} }
}; };
@ -402,42 +379,29 @@ export function ProductForm({
setUploading(true); setUploading(true);
try { try {
// 读取文件为 base64 // TODO: 文件上传接口待后端实现
const reader = new FileReader(); const formDataObj = new FormData();
reader.onload = async (event) => { formDataObj.append('file', file);
const base64 = (event.target?.result as string).split(',')[1]; formDataObj.append('bucket', 'product_images');
formDataObj.append('path_prefix', 'products');
// 生成唯一文件名 const res = await fetch('/api/v1/common/upload', {
const fileExt = file.name.split('.').pop(); method: 'POST',
const fileName = `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`; body: formDataObj,
const filePath = `products/${fileName}`; });
// 上传图片到 Supabase Storage if (!res.ok) {
const { data, error } = await supabase.storage alert('图片上传失败');
.from('product_images')
.upload(filePath, decode(base64), {
contentType: file.type,
});
if (error) {
console.error('上传失败:', error);
alert('图片上传失败: ' + error.message);
setUploading(false);
return;
}
// 获取图片的公开 URL
const { data: { publicUrl } } = supabase.storage
.from('product_images')
.getPublicUrl(filePath);
setPreviewUrl(publicUrl);
setFormData(prev => ({ ...prev, image_url: publicUrl }));
setUploading(false); setUploading(false);
}; return;
reader.readAsDataURL(file); }
const json = await res.json();
const publicUrl = json.data?.url || '';
setPreviewUrl(publicUrl);
setFormData(prev => ({ ...prev, image_url: publicUrl }));
setUploading(false);
} catch (error) { } catch (error) {
console.error('上传错误:', error);
alert('图片上传失败'); alert('图片上传失败');
setUploading(false); setUploading(false);
} }

View File

@ -1,7 +1,8 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { supabase } from '../supabase/client'; import { authApi } from '../api';
import { setAccessToken } from '../api/client';
import { wsClient } from '../api/websocket';
import type { Profile, Company, UserRole, AuthState } from '../types'; import type { Profile, Company, UserRole, AuthState } from '../types';
import { encryptPhone, decryptPhone } from '../utils/crypto';
interface RegisterData { interface RegisterData {
username: string; username: string;
@ -32,195 +33,92 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 初始化:检查已有 session
useEffect(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
const { data: { session } } = await supabase.auth.getSession(); try {
if (session?.user) { await authApi.refresh();
const { data: profile } = await supabase const { user, company } = await authApi.me();
.from('profiles') setAuth({
.select('*') isLoggedIn: true,
.eq('id', session.user.id) user,
.maybeSingle(); company,
currentRole: company?.role || null
if (profile) { });
let company: Company | null = null; if (company?.role) {
if (profile.company_id) { wsClient.connect(company.role);
const { data: comp } = await supabase
.from('companies')
.select('*')
.eq('id', profile.company_id)
.maybeSingle();
company = comp;
}
setAuth({
isLoggedIn: true,
user: profile,
company,
currentRole: company?.role || null
});
} }
} catch {
// not logged in
} }
setLoading(false); setLoading(false);
}; };
initAuth(); initAuth();
// 监听 auth 状态变化 const handleExpired = () => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { wsClient.disconnect();
if (event === 'SIGNED_IN' && session?.user) { setAuth({
setTimeout(async () => { isLoggedIn: false,
const { data: profile } = await supabase user: null,
.from('profiles') company: null,
.select('*') currentRole: null
.eq('id', session.user.id) });
.maybeSingle(); };
window.addEventListener('auth:expired', handleExpired);
if (profile) { return () => window.removeEventListener('auth:expired', handleExpired);
let company: Company | null = null;
if (profile.company_id) {
const { data: comp } = await supabase
.from('companies')
.select('*')
.eq('id', profile.company_id)
.maybeSingle();
company = comp;
}
setAuth({
isLoggedIn: true,
user: profile,
company,
currentRole: company?.role || null
});
}
}, 0);
} else if (event === 'SIGNED_OUT') {
setAuth({
isLoggedIn: false,
user: null,
company: null,
currentRole: null
});
}
});
return () => subscription.unsubscribe();
}, []); }, []);
const login = useCallback(async (username: string, password: string): Promise<boolean> => { const login = useCallback(async (username: string, password: string): Promise<boolean> => {
const { error } = await supabase.auth.signInWithPassword({ try {
email: `${username}@meoo.local`, const { user, company } = await authApi.login({ username, password });
password, setAuth({
}); isLoggedIn: true,
if (error) { user,
console.error('登录失败:', error.message); company,
currentRole: company?.role || null
});
if (company?.role) {
wsClient.connect(company.role);
}
return true;
} catch {
return false; return false;
} }
return true;
}, []); }, []);
const register = useCallback(async (data: RegisterData): Promise<{ success: boolean; error?: string }> => { const register = useCallback(async (data: RegisterData): Promise<{ success: boolean; error?: string }> => {
try { try {
// 1. 创建公司 const res = await authApi.register({
const { data: companyData, error: companyError } = await supabase username: data.username,
.from('companies')
.insert({
name: data.companyName,
role: data.role,
contact_phone: data.phone
})
.select()
.single();
if (companyError || !companyData) {
console.error('创建公司失败:', companyError?.message);
return { success: false, error: '创建公司失败,请稍后重试' };
}
// 2. 注册用户
const { data: authData, error: authError } = await supabase.auth.signUp({
email: `${data.username}@meoo.local`,
password: data.password, password: data.password,
display_name: data.displayName,
company_name: data.companyName,
phone: data.phone,
role: data.role,
}); });
setAccessToken(res.access_token);
if (authError || !authData.user) { setAuth({
console.error('注册失败:', authError?.message); isLoggedIn: true,
// 回滚:删除创建的公司 user: res.user,
await supabase.from('companies').delete().eq('id', companyData.id); company: res.company,
currentRole: res.company?.role || null
// 根据错误类型返回具体错误信息 });
if (authError?.message?.includes('already registered') || authError?.message?.includes('already exists')) { if (res.company?.role) {
return { success: false, error: '用户名已被注册,请更换用户名' }; wsClient.connect(res.company.role);
}
if (authError?.message?.includes('password')) {
return { success: false, error: '密码不符合要求请使用至少6位字符' };
}
return { success: false, error: '注册失败:' + (authError?.message || '请检查信息后重试') };
} }
// 3. 创建用户资料(手机号加密存储)
const { error: profileError } = await supabase
.from('profiles')
.insert({
id: authData.user.id,
username: data.username,
display_name: data.displayName,
phone: encryptPhone(data.phone),
company_id: companyData.id,
is_master: true
});
if (profileError) {
console.error('创建用户资料失败:', profileError.message);
// 回滚:删除创建的公司
await supabase.from('companies').delete().eq('id', companyData.id);
if (profileError.message?.includes('unique constraint') || profileError.message?.includes('duplicate')) {
return { success: false, error: '用户名已被使用,请更换用户名' };
}
return { success: false, error: '创建用户资料失败,请稍后重试' };
}
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: unknown) {
console.error('注册过程出错:', err); const message = err instanceof Error ? err.message : '注册失败,请稍后重试';
return { success: false, error: '注册过程出错:' + (err?.message || '请稍后重试') }; return { success: false, error: message };
} }
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
// 清除所有 cookie try { await authApi.logout(); } catch { /* ignore */ }
const cookies = document.cookie.split(';'); wsClient.disconnect();
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf('=');
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
// 设置过期时间为过去,删除 cookie
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`;
}
// 清除 localStorage 和 sessionStorage 中的 Supabase 相关数据
const storageKeys = Object.keys(localStorage);
for (const key of storageKeys) {
if (key.startsWith('sb-') || key.includes('supabase')) {
localStorage.removeItem(key);
}
}
const sessionKeys = Object.keys(sessionStorage);
for (const key of sessionKeys) {
if (key.startsWith('sb-') || key.includes('supabase')) {
sessionStorage.removeItem(key);
}
}
// 清除免密登录和记住密码相关存储
localStorage.removeItem('saved_username'); localStorage.removeItem('saved_username');
localStorage.removeItem('saved_password'); localStorage.removeItem('saved_password');
localStorage.removeItem('auto_login'); localStorage.removeItem('auto_login');
localStorage.removeItem('auto_login_expiry'); localStorage.removeItem('auto_login_expiry');
await supabase.auth.signOut();
setAuth({ setAuth({
isLoggedIn: false, isLoggedIn: false,
user: null, user: null,
@ -244,4 +142,4 @@ export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider'); if (!context) throw new Error('useAuth must be used within AuthProvider');
return context; return context;
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { supabase } from '../supabase/client'; import { purchaserApi, textileApi, washingApi } from '../api';
import type { ProductionPlan, WashingPlan } from '../types'; import type { ProductionPlan, WashingPlan } from '../types';
export type DashboardRole = 'purchaser' | 'textile' | 'washing'; export type DashboardRole = 'purchaser' | 'textile' | 'washing';
@ -23,10 +23,6 @@ interface DashboardDataState<T> {
rejectedPlanIds: Set<string>; rejectedPlanIds: Set<string>;
} }
/**
* Dashboard Hook
*
*/
export function useDashboardData<T = ProductionPlan | WashingPlan>({ export function useDashboardData<T = ProductionPlan | WashingPlan>({
role, role,
companyId companyId
@ -39,134 +35,61 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
rejectedPlanIds: new Set() rejectedPlanIds: new Set()
}); });
// 缓存引用,避免重复请求
const cacheRef = useRef<{ const cacheRef = useRef<{
data: DashboardDataState<T> | null; data: DashboardDataState<T> | null;
timestamp: number; timestamp: number;
}>({ data: null, timestamp: 0 }); }>({ data: null, timestamp: 0 });
const CACHE_DURATION = 5000; // 5秒缓存 const CACHE_DURATION = 5000;
// 计算统计数据 const fetchPurchaserData = useCallback(async () => {
const calculateStats = useCallback((plans: T[], roleType: DashboardRole): DashboardStats => { const [statsData, plansRes] = await Promise.all([
const all = plans.length; purchaserApi.dashboardStats(),
let producing = 0; purchaserApi.listPlans(),
let completed = 0; ]);
const plans = plansRes.data.plans as unknown as T[];
if (roleType === 'washing') { const stats: DashboardStats = {
// 水洗厂使用 processing 状态 all: statsData.total_plans,
producing = (plans as WashingPlan[]).filter(p => p.status === 'processing').length; producing: statsData.producing_plans,
completed = (plans as WashingPlan[]).filter(p => p.status === 'completed').length; completed: statsData.completed_plans,
} else { };
// 采购商和纺织厂使用 producing 状态 return { plans, stats, rejectedPlanIds: new Set<string>() };
producing = (plans as ProductionPlan[]).filter(p => p.status === 'producing').length;
completed = (plans as ProductionPlan[]).filter(p => p.status === 'completed').length;
}
return { all, producing, completed };
}, []); }, []);
// 获取采购商数据
const fetchPurchaserData = useCallback(async () => {
if (!companyId) return;
const { data: planData, error } = await supabase
.from('production_plans')
.select('*')
.eq('purchaser_id', companyId)
.order('created_at', { ascending: false });
if (error) throw error;
// 获取流程步骤,检测 rejected 状态
const planIds = (planData || []).map(p => p.id);
const { data: stepsData } = await supabase
.from('plan_process_steps')
.select('plan_id, status')
.in('plan_id', planIds);
const rejectedIds = new Set<string>();
(stepsData || []).forEach(step => {
if (step.status === 'rejected') {
rejectedIds.add(step.plan_id);
}
});
const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[];
const stats = calculateStats(filteredPlans, 'purchaser');
return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds };
}, [companyId, calculateStats]);
// 获取纺织厂数据
const fetchTextileData = useCallback(async () => { const fetchTextileData = useCallback(async () => {
if (!companyId) return; const [statsData, plansRes] = await Promise.all([
textileApi.dashboardStats(),
// 先获取关联的计划ID textileApi.listPlans(),
const { data: planFactories } = await supabase ]);
.from('plan_factories') const plans = plansRes.data as unknown as T[];
.select('plan_id') const stats: DashboardStats = {
.eq('factory_id', companyId) all: statsData.total_plans,
.eq('factory_type', 'textile'); producing: statsData.producing_plans,
completed: statsData.completed_plans,
if (!planFactories || planFactories.length === 0) { };
return { plans: [] as T[], stats: { all: 0, producing: 0, completed: 0 }, rejectedPlanIds: new Set<string>() };
}
const planIds = planFactories.map(pf => pf.plan_id);
const { data: planData, error } = await supabase
.from('production_plans')
.select('*')
.in('id', planIds)
.order('created_at', { ascending: false });
if (error) throw error;
// 获取流程步骤,检测 rejected 状态
const { data: stepsData } = await supabase
.from('plan_process_steps')
.select('plan_id, status')
.in('plan_id', planIds);
const rejectedIds = new Set<string>();
(stepsData || []).forEach(step => {
if (step.status === 'rejected') {
rejectedIds.add(step.plan_id);
}
});
const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[];
const stats = calculateStats(filteredPlans, 'textile');
return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds };
}, [companyId, calculateStats]);
// 获取水洗厂数据
const fetchWashingData = useCallback(async () => {
if (!companyId) return;
const { data: washingPlans, error } = await supabase
.from('washing_plans')
.select('*')
.eq('washing_factory_id', companyId)
.order('created_at', { ascending: false });
if (error) throw error;
const plans = (washingPlans || []) as T[];
const stats = calculateStats(plans, 'washing');
return { plans, stats, rejectedPlanIds: new Set<string>() }; return { plans, stats, rejectedPlanIds: new Set<string>() };
}, [companyId, calculateStats]); }, []);
const fetchWashingData = useCallback(async () => {
const [statsData, plansRes] = await Promise.all([
washingApi.dashboardStats(),
washingApi.listPlans(),
]);
const plans = plansRes.data as unknown as T[];
const stats: DashboardStats = {
all: statsData.total_plans,
producing: statsData.processing_plans,
completed: statsData.completed_plans,
};
return { plans, stats, rejectedPlanIds: new Set<string>() };
}, []);
// 主数据获取函数
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (!companyId) { if (!companyId) {
setState(prev => ({ ...prev, loading: false })); setState(prev => ({ ...prev, loading: false }));
return; return;
} }
// 检查缓存
const now = Date.now(); const now = Date.now();
if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) { if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) {
setState(cacheRef.current.data); setState(cacheRef.current.data);
@ -174,22 +97,22 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
} }
try { try {
let result: DashboardDataState<T> | undefined; let result: { plans: T[]; stats: DashboardStats; rejectedPlanIds: Set<string> } | undefined;
switch (role) { switch (role) {
case 'purchaser': case 'purchaser':
result = await fetchPurchaserData() as DashboardDataState<T> | undefined; result = await fetchPurchaserData();
break; break;
case 'textile': case 'textile':
result = await fetchTextileData() as DashboardDataState<T> | undefined; result = await fetchTextileData();
break; break;
case 'washing': case 'washing':
result = await fetchWashingData() as DashboardDataState<T> | undefined; result = await fetchWashingData();
break; break;
} }
if (result) { if (result) {
const newState = { const newState: DashboardDataState<T> = {
plans: result.plans, plans: result.plans,
stats: result.stats, stats: result.stats,
loading: false, loading: false,
@ -208,12 +131,10 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
} }
}, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]); }, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]);
// 初始加载和 companyId 变化时重新获取
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
// 提供手动刷新方法
const refresh = useCallback(() => { const refresh = useCallback(() => {
cacheRef.current = { data: null, timestamp: 0 }; cacheRef.current = { data: null, timestamp: 0 };
fetchData(); fetchData();

View File

@ -1,14 +1,14 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { supabase } from '../supabase/client'; import { purchaserApi } from '../api';
import { CACHE_CONFIG } from '../config/app'; import { CACHE_CONFIG } from '../config/app';
import { useInventoryRealtime } from './useRealtime'; import { useInventoryRealtime } from './useRealtime';
import type { Tables } from '../supabase/types';
import type { import type {
Product, Product,
ProductWithInventory, ProductWithInventory,
ProductYarnRatio, ProductYarnRatio,
FabricBatch, FabricBatch,
ProductInRecord ProductInRecord,
FactoryInventory,
} from '../types'; } from '../types';
interface UseInventoryDataOptions { interface UseInventoryDataOptions {
@ -24,32 +24,6 @@ interface InventoryDataState {
error: Error | null; error: Error | null;
} }
// Supabase Row 类型别名
type InventoryRecord = Tables<'inventory_records'>;
type ProductInventoryRecord = Tables<'product_inventory_records'>;
type ProductOutboundRecord = Tables<'product_outbound_records'>;
// 部分字段查询的局部类型(避免使用 any
interface PlanSummary {
id: string;
fabric_code: string;
completed_quantity: number;
}
interface PlanFactoryRef {
plan_id: string;
factory_id: string;
}
interface CompanyRef {
id: string;
name: string;
}
/**
* Hook
* 使
*/
export function useInventoryData({ companyId, activeType }: UseInventoryDataOptions) { export function useInventoryData({ companyId, activeType }: UseInventoryDataOptions) {
const [state, setState] = useState<InventoryDataState>({ const [state, setState] = useState<InventoryDataState>({
products: [], products: [],
@ -76,7 +50,6 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
const cacheKey = `${companyId}-${activeType}`; const cacheKey = `${companyId}-${activeType}`;
const now = Date.now(); const now = Date.now();
// 检查缓存
if ( if (
cacheRef.current.data && cacheRef.current.data &&
cacheRef.current.key === cacheKey && cacheRef.current.key === cacheKey &&
@ -89,135 +62,86 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
setState(prev => ({ ...prev, loading: true, error: null })); setState(prev => ({ ...prev, loading: true, error: null }));
try { try {
// 并行获取数据 const productsRes = await purchaserApi.listProducts();
const [productsResult, batchesResult, outboundResult] = await Promise.all([ const products: Product[] = productsRes.data;
// 获取产品列表
supabase
.from('products')
.select('*')
.eq('company_id', companyId)
.order('created_at', { ascending: false }),
// 获取入库记录
supabase
.from('product_inventory_records')
.select('*')
.eq('company_id', companyId)
.order('created_at', { ascending: false }),
// 获取出库记录
supabase
.from('product_outbound_records')
.select('*')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
]);
if (productsResult.error) throw productsResult.error; const recordResults = await Promise.all(
if (batchesResult.error) throw batchesResult.error; products.map(p =>
if (outboundResult.error) throw outboundResult.error; purchaserApi.getRecords(p.id).catch(() => [] as ProductInRecord[])
)
);
const productsData = productsResult.data || []; const allBatches: FabricBatch[] = [];
const batchesData: ProductInventoryRecord[] = batchesResult.data || []; const updatedProducts: ProductWithInventory[] = products.map((p, i) => {
const outboundData: ProductOutboundRecord[] = outboundResult.data || []; const records = recordResults[i];
const inRecords = records.filter(r => r.record_type !== 'out');
const outRecords = records.filter(r => r.record_type === 'out');
// 获取纱线配比 const totalInRolls = inRecords.reduce((sum, r) => sum + (r.rolls || 0), 0);
const productIds = productsData.map(p => p.id); const totalInMeters = inRecords.reduce((sum, r) => sum + (r.meters || 0), 0);
let ratiosMap: Record<string, ProductYarnRatio[]> = {}; const totalOutRolls = outRecords.reduce((sum, r) => sum + (r.rolls || 0), 0);
const totalOutMeters = outRecords.reduce((sum, r) => sum + (r.meters || 0), 0);
if (productIds.length > 0) { const factoryInventories: Record<string, FactoryInventory> = {};
const { data: ratiosData, error: ratiosError } = await supabase inRecords
.from('product_yarn_ratios') .filter(r => r.source === 'textile' && r.factory_name)
.select('*') .forEach(r => {
.in('product_id', productIds); const key = r.factory_name!;
if (!factoryInventories[key]) {
factoryInventories[key] = {
factory_id: r.factory_id || 'unknown',
factory_name: key,
rolls: 0,
meters: 0,
records: [],
};
}
factoryInventories[key].rolls += r.rolls || 0;
factoryInventories[key].meters += r.meters || 0;
factoryInventories[key].records.push(r);
});
if (ratiosError) throw ratiosError; inRecords.forEach(r => {
allBatches.push({
ratiosData?.forEach((r: ProductYarnRatio) => { id: r.id,
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = []; product_id: r.product_id,
ratiosMap[r.product_id].push(r); company_id: r.company_id || '',
batch_no: r.batch_no,
rolls: r.rolls,
meters: r.meters,
notes: r.notes,
created_at: r.created_at,
product: p,
source: r.source,
factory_name: r.factory_name,
factory_id: r.factory_id,
} as FabricBatch);
}); });
}
// 获取纺织厂入库数据
const fabricCodes = productsData.map(p => p.fabric_code);
const { data: plansData } = await supabase
.from('production_plans')
.select('id, fabric_code, completed_quantity')
.eq('purchaser_id', companyId)
.in('fabric_code', fabricCodes);
const typedPlansData: PlanSummary[] = plansData || [];
const planIds = typedPlansData.map(p => p.id).filter(Boolean);
// 获取工厂信息
const { data: planFactoriesData } = await supabase
.from('plan_factories')
.select('plan_id, factory_id')
.in('plan_id', planIds)
.eq('factory_type', 'textile');
const typedPlanFactories: PlanFactoryRef[] = planFactoriesData || [];
const factoryIds = [...new Set(typedPlanFactories.map(pf => pf.factory_id).filter(Boolean))];
const { data: factoriesData } = await supabase
.from('companies')
.select('id, name')
.in('id', factoryIds);
const typedFactories: CompanyRef[] = factoriesData || [];
// 建立映射
const planToFactoryName: Record<string, string> = {};
typedPlanFactories.forEach((pf: PlanFactoryRef) => {
const factory = typedFactories.find(f => f.id === pf.factory_id);
if (factory) {
planToFactoryName[pf.plan_id] = factory.name;
}
});
// 获取入库记录
let textileInventoryRecords: InventoryRecord[] = [];
if (planIds.length > 0) {
const { data: records } = await supabase
.from('inventory_records')
.select('plan_id, quantity, rolls, warehouse_location')
.in('plan_id', planIds);
textileInventoryRecords = (records || []) as InventoryRecord[];
}
// 处理数据
const updatedProducts = processInventoryData({
products: productsData,
plansData: typedPlansData,
textileInventoryRecords,
planToFactoryName,
batchesData,
outboundData
});
// 为 batches 数据注入产品信息
const batchesWithProduct: FabricBatch[] = batchesData.map((batch: ProductInventoryRecord) => {
const product = productsData.find((p: Product) => p.id === batch.product_id);
return { return {
id: batch.id, ...p,
product_id: batch.product_id, production_price: p.production_price || 0,
company_id: batch.company_id || '', raw_fabric_rolls: Math.max(0, totalInRolls - totalOutRolls),
batch_no: batch.batch_no, raw_fabric_meters: Math.max(0, totalInMeters - totalOutMeters),
rolls: batch.rolls, total_stock: Math.max(0, totalInMeters - totalOutMeters),
meters: batch.meters, factory_inventories: factoryInventories,
notes: batch.notes, in_records: inRecords,
created_at: batch.created_at, out_records: outRecords,
product: product || undefined total_in_rolls: totalInRolls,
} as FabricBatch; total_in_meters: totalInMeters,
total_out_rolls: totalOutRolls,
total_out_meters: totalOutMeters,
} as ProductWithInventory;
}); });
const newState: InventoryDataState = { const newState: InventoryDataState = {
products: updatedProducts, products: updatedProducts,
productRatios: ratiosMap, productRatios: {},
batches: batchesWithProduct, batches: allBatches,
loading: false, loading: false,
error: null error: null
}; };
// 更新缓存
cacheRef.current = { cacheRef.current = {
data: newState, data: newState,
timestamp: now, timestamp: now,
@ -238,9 +162,7 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
// 使用统一的实时订阅管理器
useInventoryRealtime(companyId, () => { useInventoryRealtime(companyId, () => {
// 清除缓存并刷新
cacheRef.current.timestamp = 0; cacheRef.current.timestamp = 0;
fetchData(); fetchData();
}); });
@ -250,189 +172,3 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
refetch: fetchData refetch: fetchData
}; };
} }
// 处理库存数据的辅助函数
interface ProcessInventoryDataParams {
products: Product[];
plansData: PlanSummary[];
textileInventoryRecords: InventoryRecord[];
planToFactoryName: Record<string, string>;
batchesData: ProductInventoryRecord[];
outboundData: ProductOutboundRecord[];
}
function processInventoryData({
products,
plansData,
textileInventoryRecords,
planToFactoryName,
batchesData,
outboundData
}: ProcessInventoryDataParams): ProductWithInventory[] {
// 建立 plan_id 到 fabric_code 的映射
const planToFabricCode: Record<string, string> = {};
const planCompletedMap: Record<string, number> = {};
plansData.forEach((plan: PlanSummary) => {
planToFabricCode[plan.id] = plan.fabric_code;
planCompletedMap[plan.id] = plan.completed_quantity || 0;
});
// 按 plan_id 汇总库存
const planInventoryMap: Record<string, { meters: number; rolls: number; factory_id: string; factory_name: string }> = {};
textileInventoryRecords.forEach((r: InventoryRecord) => {
const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂';
const factoryId = planToFactoryName[r.plan_id] || r.warehouse_location || `factory_${r.plan_id}`;
if (!planInventoryMap[r.plan_id]) {
planInventoryMap[r.plan_id] = { meters: 0, rolls: 0, factory_id: factoryId, factory_name: factoryName };
}
planInventoryMap[r.plan_id].meters += (Number(r.quantity) || 0);
planInventoryMap[r.plan_id].rolls += (Number(r.rolls) || 0);
});
// 汇总每个坯布码的库存
const fabricCodeInventory: Record<string, { meters: number; rolls: number }> = {};
const fabricCodeFactoryInventory: Record<string, Record<string, { factory_id: string; factory_name: string; rolls: number; meters: number; records: ProductInRecord[] }>> = {};
Object.entries(planInventoryMap).forEach(([planId, inventory]) => {
const fabricCode = planToFabricCode[planId];
if (fabricCode) {
if (!fabricCodeInventory[fabricCode]) {
fabricCodeInventory[fabricCode] = { meters: 0, rolls: 0 };
}
fabricCodeInventory[fabricCode].meters += inventory.meters || 0;
fabricCodeInventory[fabricCode].rolls += inventory.rolls || 0;
if (!fabricCodeFactoryInventory[fabricCode]) {
fabricCodeFactoryInventory[fabricCode] = {};
}
const factoryKey = inventory.factory_name || 'unknown';
if (!fabricCodeFactoryInventory[fabricCode][factoryKey]) {
fabricCodeFactoryInventory[fabricCode][factoryKey] = {
factory_id: inventory.factory_id || 'unknown',
factory_name: inventory.factory_name || '纺织厂',
rolls: 0,
meters: 0,
records: []
};
}
fabricCodeFactoryInventory[fabricCode][factoryKey].rolls += inventory.rolls || 0;
fabricCodeFactoryInventory[fabricCode][factoryKey].meters += inventory.meters || 0;
}
});
// 处理入库记录 - 合并自录入和纺织厂入库
const inRecordsMap: Record<string, ProductInRecord[]> = {};
// 1. 先添加自录入的入库记录
batchesData.forEach((b: ProductInventoryRecord) => {
if (!inRecordsMap[b.product_id]) {
inRecordsMap[b.product_id] = [];
}
inRecordsMap[b.product_id].push({
id: b.id,
product_id: b.product_id,
company_id: b.company_id,
batch_no: b.batch_no,
rolls: b.rolls || 0,
meters: b.meters || 0,
notes: b.notes,
operator_id: b.operator_id,
warehouse_id: b.warehouse_id,
created_at: b.created_at,
time: b.created_at,
source: 'self'
});
});
// 2. 添加纺织厂的入库记录
// 建立 fabric_code 到 product_id 的映射
const fabricCodeToProductId: Record<string, string> = {};
products.forEach((p: Product) => {
fabricCodeToProductId[p.fabric_code] = p.id;
});
// 将纺织厂入库记录按 fabric_code 关联到对应产品
textileInventoryRecords.forEach((r: InventoryRecord) => {
const fabricCode = planToFabricCode[r.plan_id];
const productId = fabricCodeToProductId[fabricCode];
if (productId) {
if (!inRecordsMap[productId]) {
inRecordsMap[productId] = [];
}
const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂';
inRecordsMap[productId].push({
id: r.id || `${r.plan_id}-${r.created_at}`,
product_id: productId,
company_id: r.company_id,
batch_no: '-',
rolls: Number(r.rolls) || 0,
meters: Number(r.quantity) || 0,
notes: `来自${factoryName}`,
operator_id: r.operator_id,
warehouse_id: r.warehouse_id,
created_at: r.created_at,
time: r.created_at,
factory_name: factoryName,
factory_id: r.plan_id,
source: 'textile'
});
}
});
// 按时间排序
Object.keys(inRecordsMap).forEach(productId => {
inRecordsMap[productId].sort((a, b) => new Date(b.time || b.created_at).getTime() - new Date(a.time || a.created_at).getTime());
});
// 处理出库记录 - 按 product_id 汇总
const outboundMap: Record<string, { totalRolls: number; totalMeters: number; records: ProductOutboundRecord[] }> = {};
outboundData.forEach((r: ProductOutboundRecord) => {
if (!outboundMap[r.product_id]) {
outboundMap[r.product_id] = { totalRolls: 0, totalMeters: 0, records: [] };
}
outboundMap[r.product_id].totalRolls += Number(r.rolls) || 0;
outboundMap[r.product_id].totalMeters += Number(r.meters) || 0;
outboundMap[r.product_id].records.push(r);
});
// 计算每个产品的自录入库总量(从 batchesData
const selfInventoryMap: Record<string, { rolls: number; meters: number }> = {};
batchesData.forEach((b: ProductInventoryRecord) => {
if (!selfInventoryMap[b.product_id]) {
selfInventoryMap[b.product_id] = { rolls: 0, meters: 0 };
}
selfInventoryMap[b.product_id].rolls += Number(b.rolls) || 0;
selfInventoryMap[b.product_id].meters += Number(b.meters) || 0;
});
// 更新产品数据 - 扣除出库数量
return products.map((p: Product) => {
const textileInventory = fabricCodeInventory[p.fabric_code] || { meters: 0, rolls: 0 };
const factoryInventories = fabricCodeFactoryInventory[p.fabric_code] || {};
// 使用实时计算的入库记录,而不是 products 表中的字段
const selfInventory = selfInventoryMap[p.id] || { rolls: 0, meters: 0 };
const outboundInfo = outboundMap[p.id] || { totalRolls: 0, totalMeters: 0, records: [] };
// 计算实际可用库存(入库 - 出库)
const totalInRolls = selfInventory.rolls + textileInventory.rolls;
const totalInMeters = selfInventory.meters + textileInventory.meters;
const availableRolls = Math.max(0, totalInRolls - outboundInfo.totalRolls);
const availableMeters = Math.max(0, totalInMeters - outboundInfo.totalMeters);
return {
...p,
production_price: p.production_price || 0,
raw_fabric_rolls: availableRolls,
raw_fabric_meters: availableMeters,
total_stock: availableMeters,
factory_inventories: factoryInventories,
in_records: inRecordsMap[p.id] || [],
out_records: outboundInfo.records,
total_in_rolls: totalInRolls,
total_in_meters: totalInMeters,
total_out_rolls: outboundInfo.totalRolls,
total_out_meters: outboundInfo.totalMeters
};
}) as ProductWithInventory[];
}

View File

@ -1,16 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { supabase } from '../supabase/client'; import { purchaserApi } from '../api';
import { statusMap } from '../utils/constants'; import type { PlanDetail } from '../api/purchaser';
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app'; import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
import { usePlanRealtime } from './useRealtime'; import { usePlanRealtime } from './useRealtime';
import type { import type {
ProductionPlan,
PlanWithSteps, PlanWithSteps,
PlanProcessStep,
Company, Company,
PlanFactory, PlanFactory,
YarnRatio, YarnRatio,
Product
} from '../types'; } from '../types';
interface UsePlanDataOptions { interface UsePlanDataOptions {
@ -31,10 +28,15 @@ interface PlanDataState {
error: Error | null; error: Error | null;
} }
/** function mapPlanDetail(plan: PlanDetail): PlanWithSteps {
* Hook return {
* ...plan,
*/ processSteps: plan.process_steps,
inventoryRecords: plan.inventory_records,
priceHistory: plan.price_history,
};
}
export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) { export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) {
const [state, setState] = useState<PlanDataState>({ const [state, setState] = useState<PlanDataState>({
plans: [], plans: [],
@ -49,7 +51,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
error: null error: null
}); });
// 使用 ref 存储缓存,避免不必要的重渲染
const cacheRef = useRef<{ const cacheRef = useRef<{
data: PlanDataState | null; data: PlanDataState | null;
timestamp: number; timestamp: number;
@ -57,83 +58,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
const CACHE_DURATION = CACHE_CONFIG.planDataDuration; const CACHE_DURATION = CACHE_CONFIG.planDataDuration;
// 获取操作人名称
const fetchOperatorNames = useCallback(async (operatorIds: string[]) => {
if (operatorIds.length === 0) return {};
const { data: profilesData } = await supabase
.from('profiles')
.select('id, username')
.in('id', operatorIds);
const names: Record<string, string> = {};
profilesData?.forEach(p => {
names[p.id] = p.username;
});
return names;
}, []);
// 处理计划数据
const processPlansData = useCallback((
planData: ProductionPlan[],
stepsData: PlanProcessStep[] | null,
inventoryData: any[] | null,
priceHistoryData: any[] | null,
yarnRatiosData: any[] | null,
productsData: Product[] | null
): PlanWithSteps[] => {
return planData.map(plan => {
const inventoryRecords = (inventoryData || [])
.filter(r => r.plan_id === plan.id)
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
const priceHistory = (priceHistoryData || [])
.filter(h => h.plan_id === plan.id)
.map(h => ({
id: h.id,
old_price: h.old_price,
new_price: h.new_price,
change_amount: h.change_amount,
operator_name: h.operator_name,
change_reason: h.change_reason,
created_at: h.created_at
}));
const yarnRatios = (yarnRatiosData || [])
.filter(y => y.plan_id === plan.id)
.map(y => ({
id: y.id,
plan_id: y.plan_id,
yarn_name: y.yarn_name,
ratio: y.ratio,
amount_per_meter: y.amount_per_meter,
total_amount: y.total_amount,
yarn_type: y.yarn_type,
created_at: y.created_at
}));
// 查找关联的产品信息通过fabric_code匹配
const product = (productsData || []).find(p =>
p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id
);
return {
...plan,
processSteps: (stepsData || []).filter(s => s.plan_id === plan.id),
inventoryRecords,
priceHistory,
yarnRatios,
// 从产品表获取的字段
image_url: product?.image_url || null,
warp_weight: product?.warp_weight || null,
weft_weight: product?.weft_weight || null,
total_weight: product?.total_weight || null,
weight: product?.weight || null
};
});
}, []);
// 获取数据
const fetchData = useCallback(async (page: number = 1, append: boolean = false) => { const fetchData = useCallback(async (page: number = 1, append: boolean = false) => {
if (!companyId) { if (!companyId) {
setState(prev => ({ ...prev, loading: false })); setState(prev => ({ ...prev, loading: false }));
@ -141,9 +65,8 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
} }
const isFirstPage = page === 1; const isFirstPage = page === 1;
if (isFirstPage) { if (isFirstPage) {
// 检查缓存
const now = Date.now(); const now = Date.now();
if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) { if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) {
setState(cacheRef.current.data); setState(cacheRef.current.data);
@ -155,114 +78,32 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
} }
try { try {
const offset = (page - 1) * pageSize; const res = await purchaserApi.listPlans({ page, page_size: pageSize });
const { plans: planDetails, factories, plan_factories, operator_names } = res.data;
// 获取计划 const plansWithSteps = planDetails.map(mapPlanDetail);
const { data: planData, error: planError } = await supabase
.from('production_plans')
.select('*')
.eq('purchaser_id', companyId)
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
if (planError) throw planError;
if (!planData || planData.length === 0) {
setState(prev => ({
...prev,
loading: false,
loadingMore: false,
hasMore: false,
plans: isFirstPage ? [] : prev.plans
}));
return;
}
const planIds = planData.map(p => p.id);
// 获取计划对应的采购商ID列表
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
// 并行获取关联数据
const [
stepsResult,
inventoryResult,
priceHistoryResult,
pfResult,
yarnRatiosResult,
productsResult
] = await Promise.all([
supabase.from('plan_process_steps').select('*').in('plan_id', planIds),
supabase
.from('inventory_records')
.select('plan_id, quantity, rolls, created_at')
.in('plan_id', planIds)
.order('created_at', { ascending: false })
.limit(100),
supabase
.from('production_price_history')
.select('*')
.in('plan_id', planIds)
.order('created_at', { ascending: false })
.limit(50),
supabase.from('plan_factories').select('*').in('plan_id', planIds),
supabase.from('yarn_ratios').select('*').in('plan_id', planIds),
// 获取采购商的产品信息(用于获取克重、图片等)
supabase
.from('products')
.select('*')
.in('company_id', purchaserIds)
]);
// 获取操作人信息
const operatorIds = [...new Set(
(stepsResult.data || [])
.filter(s => s.operator_id)
.map(s => s.operator_id)
.filter(Boolean)
)] as string[];
const operatorNames = await fetchOperatorNames(operatorIds);
// 获取工厂信息(包括所有纺织厂和水洗厂,不仅限于有关联的)
const { data: allFactories } = await supabase
.from('companies')
.select('*')
.in('role', ['textile', 'washing']);
const factories = allFactories || [];
// 处理计划数据
const plansWithSteps = processPlansData(
planData,
stepsResult.data,
inventoryResult.data,
priceHistoryResult.data,
yarnRatiosResult.data,
productsResult.data
);
// 构建纱线配比映射
const yarnRatiosMap: Record<string, YarnRatio[]> = {}; const yarnRatiosMap: Record<string, YarnRatio[]> = {};
(yarnRatiosResult.data || []).forEach((y: any) => { planDetails.forEach(p => {
if (!yarnRatiosMap[y.plan_id]) yarnRatiosMap[y.plan_id] = []; if (p.yarn_ratios && p.yarn_ratios.length > 0) {
yarnRatiosMap[y.plan_id].push(y as YarnRatio); yarnRatiosMap[p.id] = p.yarn_ratios;
}
}); });
setState(prev => { setState(prev => {
const newState: PlanDataState = { const newState: PlanDataState = {
plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps, plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps,
factories: isFirstPage ? factories : prev.factories, factories: isFirstPage ? factories : prev.factories,
planFactories: isFirstPage ? (pfResult.data || []) : prev.planFactories, planFactories: isFirstPage ? plan_factories : prev.planFactories,
operatorNames: isFirstPage ? operatorNames : { ...prev.operatorNames, ...operatorNames }, operatorNames: isFirstPage ? operator_names : { ...prev.operatorNames, ...operator_names },
yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap }, yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap },
loading: false, loading: false,
loadingMore: false, loadingMore: false,
hasMore: planData.length === pageSize, hasMore: res.meta?.has_more ?? planDetails.length === pageSize,
currentPage: page, currentPage: page,
error: null error: null
}; };
// 更新缓存
if (isFirstPage) { if (isFirstPage) {
cacheRef.current = { data: newState, timestamp: Date.now() }; cacheRef.current = { data: newState, timestamp: Date.now() };
} }
@ -277,26 +118,22 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
error: err instanceof Error ? err : new Error('Unknown error') error: err instanceof Error ? err : new Error('Unknown error')
})); }));
} }
}, [companyId, pageSize, fetchOperatorNames, processPlansData]); }, [companyId, pageSize]);
// 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (state.loadingMore || !state.hasMore) return; if (state.loadingMore || !state.hasMore) return;
fetchData(state.currentPage + 1, true); fetchData(state.currentPage + 1, true);
}, [fetchData, state.currentPage, state.hasMore, state.loadingMore]); }, [fetchData, state.currentPage, state.hasMore, state.loadingMore]);
// 刷新数据
const refetch = useCallback(() => { const refetch = useCallback(() => {
cacheRef.current.timestamp = 0; cacheRef.current.timestamp = 0;
fetchData(1, false); fetchData(1, false);
}, [fetchData]); }, [fetchData]);
// 初始加载
useEffect(() => { useEffect(() => {
fetchData(1, false); fetchData(1, false);
}, [fetchData]); }, [fetchData]);
// 使用统一的实时订阅管理器
usePlanRealtime(companyId, () => { usePlanRealtime(companyId, () => {
cacheRef.current.timestamp = 0; cacheRef.current.timestamp = 0;
fetchData(1, false); fetchData(1, false);

View File

@ -1,120 +1,20 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import { supabase } from '../supabase/client'; import { wsClient } from '../api/websocket';
import { SYNC_CONFIG } from '../config/app';
import { PLAN_STATUS, STEP_STATUS } from '../utils/constants';
interface PlanStatusCheckResult {
planId: string;
planCode: string;
stepStatus: string;
planStatus: string;
needsFix: boolean;
}
/**
*
* confirm pending
*/
export function usePlanStatusSync(companyId: string | undefined) { export function usePlanStatusSync(companyId: string | undefined) {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isCheckingRef = useRef(false);
const checkAndFixPlanStatus = useCallback(async () => { const checkAndFixPlanStatus = useCallback(async () => {
if (!companyId || isCheckingRef.current) return; // Server handles plan status sync and pushes updates via WebSocket
}, []);
isCheckingRef.current = true;
try {
// 获取当前纺织厂关联的所有计划
const { data: planFactories } = await supabase
.from('plan_factories')
.select('plan_id')
.eq('factory_id', companyId)
.eq('factory_type', 'textile');
if (!planFactories || planFactories.length === 0) {
isCheckingRef.current = false;
return;
}
const planIds = planFactories.map(pf => pf.plan_id);
// 获取这些计划的流程步骤状态
const { data: stepsData } = await supabase
.from('plan_process_steps')
.select('plan_id, step_type, status')
.in('plan_id', planIds)
.eq('step_type', 'confirm');
// 获取计划当前状态
const { data: plansData } = await supabase
.from('production_plans')
.select('id, plan_code, status')
.in('id', planIds);
if (!stepsData || !plansData) {
isCheckingRef.current = false;
return;
}
// 检查状态不一致的计划
const inconsistentPlans: PlanStatusCheckResult[] = [];
for (const plan of plansData) {
const confirmStep = stepsData.find(s => s.plan_id === plan.id);
// 如果 confirm 步骤已完成但计划状态仍为 pending则需要修复
if (confirmStep?.status === STEP_STATUS.COMPLETED && plan.status === PLAN_STATUS.PENDING) {
inconsistentPlans.push({
planId: plan.id,
planCode: plan.plan_code,
stepStatus: confirmStep.status,
planStatus: plan.status,
needsFix: true
});
}
}
// 自动修复状态不一致的计划
if (inconsistentPlans.length > 0) {
console.log(`[PlanStatusSync] 发现 ${inconsistentPlans.length} 个状态不一致的计划:`, inconsistentPlans);
for (const item of inconsistentPlans) {
const { error } = await supabase
.from('production_plans')
.update({ status: PLAN_STATUS.PRODUCING })
.eq('id', item.planId);
if (error) {
console.error(`[PlanStatusSync] 修复计划 ${item.planCode} 失败:`, error);
} else {
console.log(`[PlanStatusSync] 已自动修复计划 ${item.planCode}: pending → producing`);
}
}
}
} catch (err) {
console.error('[PlanStatusSync] 检查失败:', err);
} finally {
isCheckingRef.current = false;
}
}, [companyId]);
useEffect(() => { useEffect(() => {
if (!companyId) return; if (!companyId) return;
// 立即执行一次检查 const unsub = wsClient.subscribe('production_plans', () => {
checkAndFixPlanStatus(); // Server pushes status corrections; UI will refresh via other hooks
});
// 同步间隔由配置中心管理开发环境10秒生产环境5分钟 return unsub;
intervalRef.current = setInterval(checkAndFixPlanStatus, SYNC_CONFIG.planStatusSyncInterval); }, [companyId]);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [companyId, checkAndFixPlanStatus]);
return { checkAndFixPlanStatus }; return { checkAndFixPlanStatus };
} }

View File

@ -1,105 +1,14 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef } from 'react';
import { supabase } from '../supabase/client'; import { wsClient } from '../api/websocket';
// 订阅配置类型
interface SubscriptionConfig { interface SubscriptionConfig {
table: string; table: string;
event?: '*' | 'INSERT' | 'UPDATE' | 'DELETE'; event?: '*' | 'INSERT' | 'UPDATE' | 'DELETE';
filter?: string; filter?: string;
} }
// 回调函数类型
type ChangeCallback = () => void; type ChangeCallback = () => void;
// 全局订阅管理器(单例模式)
class RealtimeSubscriptionManager {
private static instance: RealtimeSubscriptionManager;
private subscriptions: Map<string, { count: number; channel: any }> = new Map();
private callbacks: Map<string, Set<ChangeCallback>> = new Map();
static getInstance(): RealtimeSubscriptionManager {
if (!RealtimeSubscriptionManager.instance) {
RealtimeSubscriptionManager.instance = new RealtimeSubscriptionManager();
}
return RealtimeSubscriptionManager.instance;
}
// 生成订阅唯一键
private getKey(config: SubscriptionConfig): string {
return `${config.table}:${config.event || '*'}:${config.filter || 'all'}`;
}
// 订阅
subscribe(config: SubscriptionConfig, callback: ChangeCallback): () => void {
const key = this.getKey(config);
// 添加回调
if (!this.callbacks.has(key)) {
this.callbacks.set(key, new Set());
}
this.callbacks.get(key)!.add(callback);
// 如果该订阅已存在,增加引用计数
if (this.subscriptions.has(key)) {
const sub = this.subscriptions.get(key)!;
sub.count++;
return () => this.unsubscribe(key, callback);
}
// 创建新订阅
const channel = supabase
.channel(`realtime_${key}`)
.on(
'postgres_changes',
{
event: config.event || '*',
schema: 'public',
table: config.table,
filter: config.filter
},
() => {
// 触发所有回调
this.callbacks.get(key)?.forEach(cb => cb());
}
)
.subscribe();
this.subscriptions.set(key, { count: 1, channel });
// 返回取消订阅函数
return () => this.unsubscribe(key, callback);
}
// 取消订阅
private unsubscribe(key: string, callback: ChangeCallback): void {
// 移除回调
this.callbacks.get(key)?.delete(callback);
// 减少引用计数
const sub = this.subscriptions.get(key);
if (sub) {
sub.count--;
// 如果没有引用,取消订阅
if (sub.count <= 0) {
sub.channel.unsubscribe();
this.subscriptions.delete(key);
this.callbacks.delete(key);
}
}
}
}
// 全局实例
const subscriptionManager = RealtimeSubscriptionManager.getInstance();
/**
* Hook
*
* @param config
* @param callback
* @param enabled true
*/
export function useRealtime( export function useRealtime(
config: SubscriptionConfig, config: SubscriptionConfig,
callback: ChangeCallback, callback: ChangeCallback,
@ -107,7 +16,6 @@ export function useRealtime(
): void { ): void {
const callbackRef = useRef(callback); const callbackRef = useRef(callback);
// 保持回调引用最新
useEffect(() => { useEffect(() => {
callbackRef.current = callback; callbackRef.current = callback;
}, [callback]); }, [callback]);
@ -115,30 +23,22 @@ export function useRealtime(
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
// 包装回调,确保使用最新引用
const wrappedCallback = () => { const wrappedCallback = () => {
callbackRef.current(); callbackRef.current();
}; };
// 订阅 const unsubscribe = wsClient.subscribe(config.table, wrappedCallback);
const unsubscribe = subscriptionManager.subscribe(config, wrappedCallback);
// 清理
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, [config.table, config.event, config.filter, enabled]); }, [config.table, config.event, config.filter, enabled]);
} }
/**
* Hook
* inventory_records product_inventory_records
*/
export function useInventoryRealtime( export function useInventoryRealtime(
companyId: string | null, companyId: string | null,
onChange: ChangeCallback onChange: ChangeCallback
): void { ): void {
// 使用 ref 保持回调稳定
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
useEffect(() => { useEffect(() => {
onChangeRef.current = onChange; onChangeRef.current = onChange;
@ -149,17 +49,8 @@ export function useInventoryRealtime(
const wrappedCallback = () => onChangeRef.current(); const wrappedCallback = () => onChangeRef.current();
// 订阅 inventory_records 变化 const unsub1 = wsClient.subscribe('inventory_records', wrappedCallback);
const unsub1 = subscriptionManager.subscribe( const unsub2 = wsClient.subscribe('product_inventory_records', wrappedCallback);
{ table: 'inventory_records', event: '*' },
wrappedCallback
);
// 订阅 product_inventory_records 变化
const unsub2 = subscriptionManager.subscribe(
{ table: 'product_inventory_records', event: '*' },
wrappedCallback
);
return () => { return () => {
unsub1(); unsub1();
@ -168,9 +59,6 @@ export function useInventoryRealtime(
}, [companyId]); }, [companyId]);
} }
/**
* Hook
*/
export function usePlanRealtime( export function usePlanRealtime(
companyId: string | null, companyId: string | null,
onChange: ChangeCallback onChange: ChangeCallback
@ -185,11 +73,7 @@ export function usePlanRealtime(
const wrappedCallback = () => onChangeRef.current(); const wrappedCallback = () => onChangeRef.current();
// 订阅 inventory_records 变化(影响计划完成进度) const unsub = wsClient.subscribe('inventory_records', wrappedCallback);
const unsub = subscriptionManager.subscribe(
{ table: 'inventory_records', event: '*' },
wrappedCallback
);
return () => { return () => {
unsub(); unsub();
@ -197,16 +81,6 @@ export function usePlanRealtime(
}, [companyId]); }, [companyId]);
} }
/** export function triggerRealtimeRefresh(_table: string): void {
* // no-op: server pushes changes via WebSocket
*/
export function triggerRealtimeRefresh(table: string): void {
// 触发该表的所有订阅回调
const manager = RealtimeSubscriptionManager.getInstance();
// 遍历所有订阅,找到匹配的表
manager['subscriptions'].forEach((_, key) => {
if (key.startsWith(`${table}:`)) {
manager['callbacks'].get(key)?.forEach(cb => cb());
}
});
} }

View File

@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase/client'; import { http } from '../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react'; import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react';
import { decryptShareParams } from '../utils/shareCrypto'; import { decryptShareParams } from '../utils/shareCrypto';
import { useResponsive } from '../hooks/useResponsive'; import { useResponsive } from '../hooks/useResponsive';
import { useErrorHandler } from '../hooks/useErrorHandler'; import { useErrorHandler } from '../hooks/useErrorHandler';
import { PLAN_STATUS, SHARE_LINK_STATUS } from '../utils/constants'; import { SHARE_LINK_STATUS } from '../utils/constants';
import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types'; import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types';
const containerVariants = { const containerVariants = {
@ -50,68 +50,52 @@ export function ImportPlanPage() {
useEffect(() => { useEffect(() => {
const resolveLink = async () => { const resolveLink = async () => {
if (shortCode) { if (shortCode) {
// 短链解析 try {
const { data, error } = await supabase const res = await http.get<any>(`/common/share-links/${shortCode}`);
.from('share_links') const data = res.data;
.select('*')
.eq('short_code', shortCode)
.maybeSingle();
if (data) { if (data) {
// 检查链接状态 setShareLinkStatus(data.status as ShareLinkStatus);
setShareLinkStatus(data.status as ShareLinkStatus);
// 检查是否过期 const expiresAt = data.expires_at ? new Date(data.expires_at) : null;
const expiresAt = data.expires_at ? new Date(data.expires_at) : null; const now = new Date();
const now = new Date(); if (expiresAt && expiresAt < now) {
if (expiresAt && expiresAt < now) { setLinkExpired(true);
setLinkExpired(true); await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED });
// 更新状态为过期
await supabase
.from('share_links')
.update({ status: SHARE_LINK_STATUS.EXPIRED })
.eq('short_code', shortCode);
}
// 检查是否已被使用confirmed/rejected/expired
const usedStatuses: ShareLinkStatus[] = [SHARE_LINK_STATUS.CONFIRMED as ShareLinkStatus, SHARE_LINK_STATUS.REJECTED as ShareLinkStatus, SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus, SHARE_LINK_STATUS.CANCELLED as ShareLinkStatus];
if (usedStatuses.includes(data.status as ShareLinkStatus)) {
setDecrypting(false);
return;
}
setPlanId(data.plan_id);
setFactoryType(data.factory_type as FactoryType);
setHideSensitive(data.hide_sensitive || false);
// 如果状态是 pending更新为 clicked首次点击
if (data.status === SHARE_LINK_STATUS.PENDING) {
const { error: updateError } = await supabase
.from('share_links')
.update({
status: SHARE_LINK_STATUS.CLICKED,
clicked_at: new Date().toISOString()
})
.eq('short_code', shortCode);
if (updateError) {
handleError(updateError, { userMessage: '更新链接点击状态失败', context: { shortCode } });
} else {
setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus);
} }
setLinkClicked(true);
} else if (data.status === SHARE_LINK_STATUS.CLICKED) { const usedStatuses: ShareLinkStatus[] = [SHARE_LINK_STATUS.CONFIRMED as ShareLinkStatus, SHARE_LINK_STATUS.REJECTED as ShareLinkStatus, SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus, SHARE_LINK_STATUS.CANCELLED as ShareLinkStatus];
setLinkClicked(true); if (usedStatuses.includes(data.status as ShareLinkStatus)) {
setDecrypting(false);
return;
}
setPlanId(data.plan_id);
setFactoryType(data.factory_type as FactoryType);
setHideSensitive(data.hide_sensitive || false);
if (data.status === SHARE_LINK_STATUS.PENDING) {
try {
await http.put(`/common/share-links/${shortCode}`, {
status: SHARE_LINK_STATUS.CLICKED,
clicked_at: new Date().toISOString()
});
setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus);
} catch (err) {
handleError(err, { userMessage: '更新链接点击状态失败', context: { shortCode } });
}
setLinkClicked(true);
} else if (data.status === SHARE_LINK_STATUS.CLICKED) {
setLinkClicked(true);
}
await http.post(`/common/share-links/${shortCode}/click`).catch(() => {});
} }
} catch (err) {
// 增加点击计数 handleError(err, { userMessage: '短链查询失败', context: { shortCode } });
await supabase.rpc('increment_share_link_clicks', { code: shortCode });
} else {
handleError(error, { userMessage: '短链查询失败', context: { shortCode } });
} }
setDecrypting(false); setDecrypting(false);
} else if (token) { } else if (token) {
// 长链接解析(异步,带签名验证)
const decrypted = await decryptShareParams(token); const decrypted = await decryptShareParams(token);
if (decrypted) { if (decrypted) {
setPlanId(decrypted.planId); setPlanId(decrypted.planId);
@ -132,40 +116,37 @@ export function ImportPlanPage() {
// 倒计时显示 // 倒计时显示
useEffect(() => { useEffect(() => {
if (!shortCode || !shareLinkStatus) return; if (!shortCode || !shareLinkStatus) return;
const updateTimer = async () => {
const { data } = await supabase
.from('share_links')
.select('expires_at, status')
.eq('short_code', shortCode)
.single();
if (!data || !data.expires_at) return;
const expiresAt = new Date(data.expires_at); const updateTimer = async () => {
const now = new Date(); try {
const diff = expiresAt.getTime() - now.getTime(); const res = await http.get<any>(`/common/share-links/${shortCode}`);
const data = res.data;
if (diff <= 0) { if (!data || !data.expires_at) return;
setTimeRemaining('已过期');
setLinkExpired(true); const expiresAt = new Date(data.expires_at);
if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) { const now = new Date();
await supabase const diff = expiresAt.getTime() - now.getTime();
.from('share_links')
.update({ status: SHARE_LINK_STATUS.EXPIRED }) if (diff <= 0) {
.eq('short_code', shortCode); setTimeRemaining('已过期');
setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus); setLinkExpired(true);
if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) {
await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED });
setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus);
}
} else {
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
setTimeRemaining(`${minutes}${seconds}`);
} }
} else { } catch {
const minutes = Math.floor(diff / 60000); // ignore timer errors
const seconds = Math.floor((diff % 60000) / 1000);
setTimeRemaining(`${minutes}${seconds}`);
} }
}; };
updateTimer(); updateTimer();
const timer = setInterval(updateTimer, 1000); const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [shortCode, shareLinkStatus]); }, [shortCode, shareLinkStatus]);
@ -185,40 +166,42 @@ export function ImportPlanPage() {
}, [planId]); }, [planId]);
const fetchPlanData = async () => { const fetchPlanData = async () => {
// 首先尝试从 production_plans 查询 try {
const { data: planData } = await supabase.from('production_plans').select('*').eq('id', planId).maybeSingle(); const planRes = await http.get<any>(`/common/plans/${planId}`);
const planData = planRes.data;
if (planData) { if (planData && planData.type === 'production') {
setPlan(planData); setPlan(planData);
setIsWashingPlan(false); setIsWashingPlan(false);
setPurchaserCompanyId(planData.purchaser_id); setPurchaserCompanyId(planData.purchaser_id);
const { data: yarnData } = await supabase.from('yarn_ratios').select('*').eq('plan_id', planId); setYarns(planData.yarn_ratios || []);
setYarns(yarnData || []); setPurchaserName(planData.purchaser_name || '未知');
const { data: companyData } = await supabase.from('companies').select('name').eq('id', planData.purchaser_id).maybeSingle();
setPurchaserName(companyData?.name || '未知');
if (auth.company) {
const { data: existing } = await supabase.from('plan_factories').select('id').eq('plan_id', planId).eq('factory_id', auth.company.id).maybeSingle();
setAlreadyImported(!!existing);
}
} else {
// 尝试从 washing_plans 查询
const { data: washingPlanData } = await supabase.from('washing_plans').select('*').eq('id', planId).maybeSingle();
if (washingPlanData) {
setPlan(washingPlanData);
setIsWashingPlan(true);
setPurchaserCompanyId(washingPlanData.company_id);
const { data: companyData } = await supabase.from('companies').select('name').eq('id', washingPlanData.company_id).maybeSingle();
setPurchaserName(companyData?.name || '未知');
if (auth.company) { if (auth.company) {
const { data: existing } = await supabase.from('washing_plans').select('id').eq('id', planId).eq('washing_factory_id', auth.company.id).maybeSingle(); setAlreadyImported(!!planData.already_imported);
setAlreadyImported(!!existing); }
} else if (planData && planData.type === 'washing') {
setPlan(planData);
setIsWashingPlan(true);
setPurchaserCompanyId(planData.company_id);
setPurchaserName(planData.purchaser_name || '未知');
if (auth.company) {
setAlreadyImported(!!planData.already_imported);
}
} else if (planData) {
setPlan(planData);
setIsWashingPlan(false);
setPurchaserCompanyId(planData.purchaser_id || planData.company_id);
setPurchaserName(planData.purchaser_name || '未知');
setYarns(planData.yarn_ratios || []);
if (auth.company) {
setAlreadyImported(!!planData.already_imported);
} }
} }
} catch {
// plan not found
} }
setLoading(false); setLoading(false);
}; };
@ -228,92 +211,42 @@ export function ImportPlanPage() {
if (!auth.company || !planId || !factoryType || !purchaserCompanyId) return; if (!auth.company || !planId || !factoryType || !purchaserCompanyId) return;
setImporting(true); setImporting(true);
// 1. 创建 plan_factories 关联 try {
if (isWashingPlan) { await http.post(`/common/plans/${planId}/confirm`, {
const { error } = await supabase.from('washing_plans').update({
washing_factory_id: auth.company.id
}).eq('id', planId);
if (error) {
console.error('导入失败:', error);
setImporting(false);
return;
}
} else {
const { error } = await supabase.from('plan_factories').insert({
plan_id: planId,
factory_id: auth.company.id, factory_id: auth.company.id,
factory_type: factoryType factory_type: factoryType,
is_washing_plan: isWashingPlan,
purchaser_company_id: purchaserCompanyId,
short_code: shortCode || undefined
}); });
if (error) {
console.error('导入失败:', error); setImportSuccess(true);
setImporting(false); setImporting(false);
return; setTimeout(() => { navigate(`/${auth.currentRole}/plans`); }, 2000);
} } catch (err) {
handleError(err, { userMessage: '确认失败' });
setImporting(false);
} }
// 1.5 更新计划状态为 producing生产中
const { error: planUpdateError } = await supabase
.from('production_plans')
.update({ status: PLAN_STATUS.PRODUCING })
.eq('id', planId);
if (planUpdateError) {
handleError(planUpdateError, { userMessage: '更新计划状态失败', context: { planId } });
}
// 2. 创建/更新公司合作关系
const { error: relError } = await supabase.from('company_relationships').upsert({
purchaser_company_id: purchaserCompanyId,
factory_company_id: auth.company.id,
factory_type: factoryType,
status: 'active'
}, {
onConflict: 'purchaser_company_id,factory_company_id'
});
if (relError) {
handleError(relError, { userMessage: '创建合作关系失败', context: { purchaserCompanyId, factoryCompanyId: auth.company.id } });
}
// 3. 更新分享链接状态
if (shortCode) {
const { error: linkUpdateError } = await supabase
.from('share_links')
.update({
status: SHARE_LINK_STATUS.CONFIRMED,
responded_at: new Date().toISOString(),
response_result: 'confirmed'
})
.eq('short_code', shortCode);
if (linkUpdateError) {
handleError(linkUpdateError, { userMessage: '更新分享链接状态失败', context: { shortCode } });
}
}
setImportSuccess(true);
setImporting(false);
setTimeout(() => { navigate(`/${auth.currentRole}/plans`); }, 2000);
}; };
// 拒绝计划 // 拒绝计划
const handleReject = async () => { const handleReject = async () => {
if (!shortCode) return; if (!shortCode) return;
setImporting(true); setImporting(true);
await supabase try {
.from('share_links') await http.put(`/common/share-links/${shortCode}`, {
.update({
status: SHARE_LINK_STATUS.REJECTED, status: SHARE_LINK_STATUS.REJECTED,
responded_at: new Date().toISOString(), responded_at: new Date().toISOString(),
response_result: 'rejected', response_result: 'rejected',
reject_reason: rejectReason reject_reason: rejectReason
}) });
.eq('short_code', shortCode); } catch {
// ignore
}
setImporting(false); setImporting(false);
// 显示拒绝成功,返回首页
setTimeout(() => { navigate('/role-select'); }, 2000); setTimeout(() => { navigate('/role-select'); }, 2000);
}; };

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase/client'; import { commonApi, http } from '../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Users, ArrowLeft, Plus, X, User, Phone, Shield, Trash2, AlertCircle, Edit2, Building2, BookOpen, MessageSquare, Info } from 'lucide-react'; import { Users, ArrowLeft, Plus, X, User, Phone, Shield, Trash2, AlertCircle, Edit2, Building2, BookOpen, MessageSquare, Info } from 'lucide-react';
import { useResponsive } from '../hooks/useResponsive'; import { useResponsive } from '../hooks/useResponsive';
@ -70,39 +70,31 @@ export function MemberManage() {
const fetchCompanyInfo = async () => { const fetchCompanyInfo = async () => {
if (!auth.user?.company_id) return; if (!auth.user?.company_id) return;
const { data } = await supabase try {
.from('companies') const data = await commonApi.getCompany(auth.user.company_id);
.select('name, contact_phone, role') if (data) {
.eq('id', auth.user.company_id) setCompanyInfo(data as any);
.maybeSingle(); }
if (data) { } catch {
setCompanyInfo(data); // ignore
} }
}; };
const fetchMembers = async () => { const fetchMembers = async () => {
setLoading(true); setLoading(true);
try { try {
// 获取当前公司的所有子账号(非主账号) const data = await commonApi.listMembers();
const companyId = auth.user?.company_id; const subMembers = (data || [])
if (!companyId) { .filter((m: any) => !m.profile?.is_master)
setLoading(false); .map((m: any) => ({
return; id: m.profile?.id || m.id,
} username: m.profile?.username || '',
const { data, error } = await supabase phone: m.profile?.phone || null,
.from('profiles') created_at: m.profile?.created_at || m.created_at
.select('id, username, phone, created_at') }));
.eq('company_id', companyId) setMembers(subMembers);
.eq('is_master', false) } catch {
.order('created_at', { ascending: false }); setMembers([]);
if (error) {
console.error('获取成员列表失败:', error);
} else {
setMembers(data || []);
}
} catch (err) {
console.error('获取成员列表出错:', err);
} }
setLoading(false); setLoading(false);
}; };
@ -146,39 +138,20 @@ export function MemberManage() {
setEditingMaster(true); setEditingMaster(true);
try { try {
// 更新姓名和手机号 await http.put('/common/profile', {
const { error: updateError } = await supabase display_name: masterFormData.displayName,
.from('profiles') phone: encryptPhone(masterFormData.phone)
.update({ });
display_name: masterFormData.displayName,
phone: encryptPhone(masterFormData.phone)
})
.eq('id', auth.user?.id || '');
if (updateError) {
setError('更新失败:' + updateError.message);
setEditingMaster(false);
return;
}
// 如果填写了密码,更新密码
if (masterFormData.password && masterFormData.password.length >= 6) { if (masterFormData.password && masterFormData.password.length >= 6) {
const { error: passwordError } = await supabase.auth.updateUser({ await http.put('/common/password', {
password: masterFormData.password password: masterFormData.password
}); });
if (passwordError) {
setError('密码更新失败:' + passwordError.message);
setEditingMaster(false);
return;
}
} }
// 刷新页面以获取更新后的用户信息
window.location.reload(); window.location.reload();
} catch (err) { } catch (err: any) {
setError('更新时发生错误'); setError('更新失败:' + (err.message || ''));
console.error(err);
} }
setEditingMaster(false); setEditingMaster(false);
}; };
@ -213,74 +186,17 @@ export function MemberManage() {
setError(''); setError('');
try { try {
// 检查用户名是否已存在(全局唯一性检查) await commonApi.createMember({
const { data: existingUser, error: checkError } = await supabase username: formData.username.trim(),
.from('profiles')
.select('id')
.eq('username', formData.username.trim())
.maybeSingle();
if (checkError) {
console.error('检查用户名失败:', checkError);
}
if (existingUser) {
setError('该用户名已被使用,请更换其他用户名');
setSubmitting(false);
return;
}
// 1. 创建用户(使用虚拟邮箱格式)
const email = `${formData.username}@meoo.local`;
const { data: authData, error: signUpError } = await supabase.auth.signUp({
email,
password: formData.password, password: formData.password,
options: { display_name: formData.displayName,
data: { phone: formData.phone || undefined
username: formData.username,
phone: formData.phone || null,
company_id: auth.user.company_id,
is_master: false,
master_id: auth.user.id
}
}
}); });
if (signUpError) {
setError('创建账号失败:' + signUpError.message);
setSubmitting(false);
return;
}
if (!authData.user) {
setError('创建账号失败,请重试');
setSubmitting(false);
return;
}
// 2. 创建 profile 记录(手机号加密存储)
const { error: profileError } = await supabase
.from('profiles')
.insert({
id: authData.user.id,
username: formData.username,
display_name: formData.displayName,
phone: encryptPhone(formData.phone) || null,
company_id: auth.user.company_id,
is_master: false,
master_id: auth.user.id
});
if (profileError) {
console.error('创建 profile 失败:', profileError);
// 继续执行,因为用户已创建成功
}
// 3. 刷新成员列表
await fetchMembers(); await fetchMembers();
handleCloseModal(); handleCloseModal();
} catch (err) { } catch (err: any) {
setError('创建子账号时发生错误'); setError('创建子账号失败:' + (err.message || ''));
console.error(err);
} }
setSubmitting(false); setSubmitting(false);
@ -288,22 +204,11 @@ export function MemberManage() {
const handleDelete = async (memberId: string) => { const handleDelete = async (memberId: string) => {
try { try {
// 删除 profile 记录 await commonApi.deleteMember(memberId);
const { error: profileError } = await supabase
.from('profiles')
.delete()
.eq('id', memberId);
if (profileError) {
console.error('删除成员失败:', profileError);
return;
}
// 刷新列表
setMembers(members.filter(m => m.id !== memberId)); setMembers(members.filter(m => m.id !== memberId));
setShowDeleteConfirm(null); setShowDeleteConfirm(null);
} catch (err) { } catch {
console.error('删除成员出错:', err); // ignore
} }
}; };

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { purchaserApi, http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, Package, CheckCircle, Clock, AlertCircle } from 'lucide-react'; import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, Package, CheckCircle, Clock, AlertCircle } from 'lucide-react';
@ -52,68 +52,32 @@ export function AccountsPayable() {
const fetchPayables = async () => { const fetchPayables = async () => {
setLoading(true); setLoading(true);
// 获取应付账款列表
const { data: apData, error: apError } = await supabase
.from('accounts_payable')
.select('*')
.eq('purchaser_id', auth.company!.id)
.order('created_at', { ascending: false });
if (apError) { try {
console.error('获取应付账款失败:', apError); const res = await purchaserApi.listAccountsPayable();
setLoading(false); setPayables((res.data || []) as AccountsPayable[]);
return; } catch {
setPayables([]);
} }
// 获取工厂信息
const factoryIds = [...new Set((apData || []).map(ap => ap.textile_factory_id))];
const { data: factoriesData } = await supabase
.from('companies')
.select('id, name')
.in('id', factoryIds);
const factoryMap: Record<string, string> = {};
(factoriesData || []).forEach(f => {
factoryMap[f.id] = f.name;
});
// 获取计划信息
const planIds = [...new Set((apData || []).map(ap => ap.plan_id))];
const { data: plansData } = await supabase
.from('production_plans')
.select('id, plan_code, product_name, color')
.in('id', planIds);
const planMap: Record<string, { plan_code: string; product_name: string; color: string }> = {};
(plansData || []).forEach(p => {
planMap[p.id] = { plan_code: p.plan_code, product_name: p.product_name, color: p.color };
});
// 组合数据
const payablesWithInfo: AccountsPayable[] = (apData || []).map(ap => ({
...ap,
factory_name: factoryMap[ap.textile_factory_id] || '未知工厂',
plan_code: planMap[ap.plan_id]?.plan_code || '',
product_name: planMap[ap.plan_id]?.product_name || '',
color: planMap[ap.plan_id]?.color || ''
}));
setPayables(payablesWithInfo);
setLoading(false); setLoading(false);
}; };
const fetchPayableItems = async (payableId: string) => { const fetchPayableItems = async (payableId: string) => {
const { data: itemsData } = await supabase try {
.from('accounts_payable_items') const res = await http.get<AccountsPayableItem[]>(
.select('*') `/purchaser/accounts-payable/${payableId}/items`
.eq('accounts_payable_id', payableId) );
.order('created_at', { ascending: false }); setPayableItems(prev => ({
...prev,
setPayableItems(prev => ({ [payableId]: res.data || []
...prev, }));
[payableId]: itemsData || [] } catch {
})); setPayableItems(prev => ({
...prev,
[payableId]: []
}));
}
}; };
const toggleExpand = (payableId: string) => { const toggleExpand = (payableId: string) => {

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { purchaserApi } from '../../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
FileText, FileText,
@ -84,28 +84,12 @@ export function PurchaserDashboard() {
}, [auth.company, dataLoaded, plans.length]); }, [auth.company, dataLoaded, plans.length]);
const fetchPlans = async () => { const fetchPlans = async () => {
const { data: planData } = await supabase const res = await purchaserApi.listPlans();
.from('production_plans') const allPlans = res.data.plans;
.select('*')
.eq('purchaser_id', auth.company!.id)
.order('created_at', { ascending: false });
// 获取流程步骤,检测 rejected 状态 const filteredPlans = allPlans.filter(p =>
const planIds = (planData || []).map(p => p.id); !p.process_steps?.some(s => s.status === STEP_STATUS.REJECTED)
const { data: stepsData } = await supabase );
.from('plan_process_steps')
.select('plan_id, status')
.in('plan_id', planIds);
// 过滤掉有 rejected 步骤的计划
const rejectedPlanIds = new Set<string>();
(stepsData || []).forEach(step => {
if (step.status === STEP_STATUS.REJECTED) {
rejectedPlanIds.add(step.plan_id);
}
});
const filteredPlans = (planData || []).filter(p => !rejectedPlanIds.has(p.id));
setPlans(filteredPlans); setPlans(filteredPlans);
setLoading(false); setLoading(false);
setDataLoaded(true); setDataLoaded(true);

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react'; import { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
@ -43,30 +43,22 @@ export function FactoryManage() {
const fetchFactories = async () => { const fetchFactories = async () => {
setLoading(true); setLoading(true);
// 直接查询所有该类型的工厂(不再通过 plan_factories 关联) try {
const { data: factoryData } = await supabase const res = await http.get<Company[]>('/purchaser/factories', { role: activeTab });
.from('companies') setFactories(res.data || []);
.select('*')
.eq('role', activeTab)
.order('created_at', { ascending: false });
setFactories(factoryData || []); if (res.data && res.data.length > 0) {
const factoryIds = res.data.map(f => f.id);
// 计算每个工厂的计划数(通过 plan_factories 统计) const countsRes = await http.get<Record<string, number>>('/purchaser/factories/plan-counts', {
if (factoryData && factoryData.length > 0) { factory_ids: factoryIds.join(','),
const factoryIds = factoryData.map(f => f.id); factory_type: activeTab
const { data: planFactories } = await supabase });
.from('plan_factories') setPlanCounts(countsRes.data || {});
.select('factory_id') } else {
.in('factory_id', factoryIds) setPlanCounts({});
.eq('factory_type', activeTab); }
} catch {
const counts: Record<string, number> = {}; setFactories([]);
planFactories?.forEach(pf => {
counts[pf.factory_id] = (counts[pf.factory_id] || 0) + 1;
});
setPlanCounts(counts);
} else {
setPlanCounts({}); setPlanCounts({});
} }
setLoading(false); setLoading(false);
@ -116,67 +108,14 @@ export function FactoryManage() {
setDeleting(true); setDeleting(true);
try { try {
console.log('开始删除工厂:', deletingFactory.id, deletingFactory.name); await http.delete(`/purchaser/factories/${deletingFactory.id}`);
// 检查该工厂是否有关联的计划
const { data: planFactories, error: checkError } = await supabase
.from('plan_factories')
.select('id')
.eq('factory_id', deletingFactory.id)
.limit(1);
if (checkError) {
handleError(checkError, { userMessage: '检查关联计划失败' });
setDeleting(false);
return;
}
if (planFactories && planFactories.length > 0) {
handleError(new Error('无法删除'), { userMessage: '该工厂已有关联计划,无法删除' });
setDeleting(false);
return;
}
console.log('准备删除工厂ID:', deletingFactory.id);
// 先删除关联的合作关系记录
const { error: relError } = await supabase
.from('company_relationships')
.delete()
.eq('factory_company_id', deletingFactory.id);
if (relError) {
handleError(relError, { userMessage: '删除合作关系失败' });
setDeleting(false);
return;
}
console.log('已删除关联的合作关系');
// 删除工厂
const { data: deleteData, error: deleteError } = await supabase
.from('companies')
.delete()
.eq('id', deletingFactory.id)
.select();
if (deleteError) {
handleError(deleteError, { userMessage: '删除工厂失败' });
setDeleting(false);
return;
}
console.log('删除成功:', deleteData);
// 刷新列表
await fetchFactories(); await fetchFactories();
// 如果删除的是当前展开的工厂,关闭展开状态
if (expandedId === deletingFactory.id) { if (expandedId === deletingFactory.id) {
setExpandedId(null); setExpandedId(null);
} }
handleCloseDeleteModal(); handleCloseDeleteModal();
} catch (err) { } catch (err) {
handleError(err, { userMessage: '删除工厂时发生错误' }); handleError(err, { userMessage: '删除工厂失败' });
} }
setDeleting(false); setDeleting(false);
}; };
@ -189,26 +128,16 @@ export function FactoryManage() {
// 检查工厂名称是否已存在(同一类型下) // 检查工厂名称是否已存在(同一类型下)
const checkDuplicateName = async (name: string, excludeId?: string): Promise<boolean> => { const checkDuplicateName = async (name: string, excludeId?: string): Promise<boolean> => {
const { data, error } = await supabase try {
.from('companies') const res = await http.get<{ exists: boolean }>('/purchaser/factories/check-name', {
.select('id') role: activeTab,
.eq('role', activeTab) name: name.trim(),
.ilike('name', name.trim()) exclude_id: excludeId
.limit(1); });
return res.data.exists;
if (error) { } catch {
console.error('检查重名失败:', error);
return false; return false;
} }
// 如果存在记录且不是当前编辑的工厂,则认为是重名
if (data && data.length > 0) {
if (excludeId && data[0].id === excludeId) {
return false; // 是自己,不算重名
}
return true;
}
return false;
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -228,7 +157,6 @@ export function FactoryManage() {
setError(''); setError('');
try { try {
// 检查重名
const isDuplicate = await checkDuplicateName(formData.name, editingFactory?.id); const isDuplicate = await checkDuplicateName(formData.name, editingFactory?.id);
if (isDuplicate) { if (isDuplicate) {
setError(`${activeTab === 'textile' ? '织布厂' : '水洗厂'}名称已存在,请使用其他名称`); setError(`${activeTab === 'textile' ? '织布厂' : '水洗厂'}名称已存在,请使用其他名称`);
@ -237,48 +165,24 @@ export function FactoryManage() {
} }
if (editingFactory) { if (editingFactory) {
// 更新工厂信息 await http.put(`/purchaser/factories/${editingFactory.id}`, {
const { error: updateError } = await supabase name: formData.name,
.from('companies') contact_phone: formData.contactPhone,
.update({ address: formData.address
name: formData.name, });
contact_phone: formData.contactPhone,
address: formData.address
})
.eq('id', editingFactory.id);
if (updateError) {
setError('更新工厂失败:' + updateError.message);
setSubmitting(false);
return;
}
} else { } else {
// 创建工厂(作为公司) await http.post('/purchaser/factories', {
const { data: newFactory, error: factoryError } = await supabase name: formData.name,
.from('companies') role: activeTab,
.insert({ contact_phone: formData.contactPhone,
name: formData.name, address: formData.address
role: activeTab, });
contact_phone: formData.contactPhone,
address: formData.address
})
.select()
.single();
if (factoryError || !newFactory) {
setError('创建工厂失败:' + factoryError?.message);
setSubmitting(false);
return;
}
// 工厂创建成功,直接显示在列表中(不再需要通过 plan_factories 关联)
} }
// 刷新工厂列表
await fetchFactories(); await fetchFactories();
handleCloseModal(); handleCloseModal();
} catch (err) { } catch (err: any) {
setError(editingFactory ? '更新工厂时发生错误' : '创建工厂时发生错误'); setError(editingFactory ? '更新工厂失败:' + (err.message || '') : '创建工厂失败:' + (err.message || ''));
console.error(err);
} }
setSubmitting(false); setSubmitting(false);

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, Package, Search, History, ArrowUpRight, ArrowDownRight, Boxes, Shirt } from 'lucide-react'; import { ArrowLeft, Package, Search, History, ArrowUpRight, ArrowDownRight, Boxes, Shirt } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
@ -37,36 +37,23 @@ export function FinishedWarehouse() {
const fetchProducts = async () => { const fetchProducts = async () => {
setLoading(true); setLoading(true);
const { data, error } = await supabase try {
.from('finished_products') const res = await http.get<FinishedProductWithDetails[]>('/purchaser/finished-products');
.select(` setProducts(res.data || []);
*, } catch {
warehouse:warehouse_id(name) setProducts([]);
`)
.eq('company_id', auth.company!.id)
.order('created_at', { ascending: false });
if (error) {
console.error('获取成品失败:', error);
} else {
const productsWithDetails = (data || []).map(product => ({
...product,
warehouse_name: (product.warehouse as any)?.name
}));
setProducts(productsWithDetails);
} }
setLoading(false); setLoading(false);
}; };
const fetchInventoryRecords = async (productId: string) => { const fetchInventoryRecords = async (productId: string) => {
const { data, error } = await supabase try {
.from('finished_product_inventory_records') const res = await http.get<FinishedProductInventoryRecord[]>(
.select('*') `/purchaser/finished-products/${productId}/records`
.eq('finished_product_id', productId) );
.order('created_at', { ascending: false }); setInventoryRecords(res.data || []);
} catch {
if (!error) { setInventoryRecords([]);
setInventoryRecords(data || []);
} }
}; };

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { purchaserApi } from '../../api';
import { http } from '../../api';
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react'; import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useErrorHandler } from '../../hooks/useErrorHandler'; import { useErrorHandler } from '../../hooks/useErrorHandler';
import type { Company, FactoryType } from '../../types'; import type { Company, FactoryType } from '../../types';
import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
interface YarnInput { interface YarnInput {
id: string; id: string;
@ -88,26 +88,21 @@ export function NewPlan() {
if (!auth.company) return; if (!auth.company) return;
setLoading(true); setLoading(true);
const { data: prodData } = await supabase const res = await purchaserApi.listProducts();
.from('products') const prodData = res.data;
.select('*')
.eq('company_id', auth.company.id)
.order('created_at', { ascending: false });
if (prodData) { if (prodData) {
setProducts(prodData as Product[]); setProducts(prodData as Product[]);
// 获取每个产品的纱线配比
const productIds = prodData.map(p => p.id); const productIds = prodData.map(p => p.id);
if (productIds.length > 0) { if (productIds.length > 0) {
const { data: ratiosData } = await supabase const ratiosRes = await http.get<ProductYarnRatio[]>('/purchaser/products/yarn-ratios', {
.from('product_yarn_ratios') product_ids: productIds.join(',')
.select('*') });
.in('product_id', productIds);
if (ratiosData) { if (ratiosRes.data) {
const ratiosMap: Record<string, ProductYarnRatio[]> = {}; const ratiosMap: Record<string, ProductYarnRatio[]> = {};
ratiosData.forEach((r: any) => { ratiosRes.data.forEach((r: any) => {
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = []; if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
ratiosMap[r.product_id].push(r as ProductYarnRatio); ratiosMap[r.product_id].push(r as ProductYarnRatio);
}); });
@ -120,14 +115,7 @@ export function NewPlan() {
const fetchFactories = async () => { const fetchFactories = async () => {
if (!auth.company) return; if (!auth.company) return;
const data = await purchaserApi.listFactories();
// 查询工厂管理中登记的纺织厂companies表中role=textile的记录
const { data } = await supabase
.from('companies')
.select('*')
.eq('role', 'textile')
.order('created_at', { ascending: false });
setFactories(data || []); setFactories(data || []);
}; };
@ -199,97 +187,21 @@ export function NewPlan() {
setSubmitting(true); setSubmitting(true);
try { try {
// 生成计划编号 await purchaserApi.createPlan({
const timestamp = Date.now().toString().slice(-6); product_name: productName,
const planCode = `PLAN-2026-${timestamp}`; fabric_code: fabricCode.toUpperCase(),
color_code: colorCode,
// 创建计划 color: color,
const { data: plan, error: planError } = await supabase target_quantity: parseInt(targetQuantity) || 0,
.from('production_plans') unit_price: parseFloat(productionPrice) || 0,
.insert({ notes: remark,
plan_code: planCode, yarn_ratios: yarns.filter(y => y.name).map(yarn => ({
product_name: productName, yarn_name: yarn.name,
color: color, ratio: yarn.ratio,
fabric_code: fabricCode.toUpperCase(), amount_per_meter: (selectedProduct?.weight || 0) * yarn.ratio,
color_code: colorCode, yarn_type: yarn.yarn_type
remark: remark, }))
purchaser_id: auth.company.id, });
target_quantity: parseInt(targetQuantity) || 0,
yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量
production_price: parseFloat(productionPrice) || 0,
status: PLAN_STATUS.PENDING,
created_by: auth.user?.id
})
.select()
.maybeSingle();
if (planError) {
handleError(planError, { userMessage: '创建计划失败' });
setSubmitting(false);
return;
}
if (plan) {
// 创建纱线配比
const yarnErrors: string[] = [];
for (const yarn of yarns) {
if (yarn.name) {
const amountPerMeter = selectedProduct?.weight || 0;
const totalAmount = amountPerMeter * yarn.ratio * (parseInt(targetQuantity) || 0);
const { error: yarnError } = await supabase.from('yarn_ratios').insert({
plan_id: plan.id,
yarn_name: yarn.name,
ratio: yarn.ratio,
amount_per_meter: amountPerMeter * yarn.ratio,
total_amount: totalAmount
});
if (yarnError) {
yarnErrors.push(yarnError.message);
console.error('纱线配比创建失败:', yarnError);
}
}
}
// 创建流程节点
const stepErrors: string[] = [];
const steps: ('confirm' | 'yarn_purchase' | 'dyeing' | 'machine_start' | 'fabric_warehouse')[] = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
for (const step of steps) {
const { error: stepError } = await supabase.from('plan_process_steps').insert({
plan_id: plan.id,
step_type: step,
status: STEP_STATUS.PENDING
});
if (stepError) {
stepErrors.push(stepError.message);
console.error('流程节点创建失败:', stepError);
}
}
// 记录初始价格旧价格为0表示新建
const finalPrice = parseFloat(productionPrice) || 0;
if (finalPrice > 0) {
const { error: priceError } = await supabase.from('production_price_history').insert({
plan_id: plan.id,
old_price: 0,
new_price: finalPrice,
change_amount: finalPrice,
operator_id: auth.user?.id,
operator_name: auth.user?.username || '',
change_reason: '创建计划时设置初始价格'
});
if (priceError) {
console.error('价格历史记录失败:', priceError);
}
}
// 注意:工厂关联不再在创建计划时自动进行
// 而是通过分享链接或推送功能,由工厂确认后才建立关联
// 如果有子操作失败,提示用户但允许继续
if (yarnErrors.length > 0 || stepErrors.length > 0) {
console.warn('计划创建完成,但部分数据创建失败:', { yarnErrors, stepErrors });
}
}
alert('计划创建成功!'); alert('计划创建成功!');
navigate('/purchaser/plans'); navigate('/purchaser/plans');

View File

@ -1,12 +1,11 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { purchaserApi, http } from '../../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react'; import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import type { ProductWithInventory, Company } from '../../types'; import type { ProductWithInventory, Company } from '../../types';
import { WASHING_PLAN_STATUS } from '../../utils/constants';
interface WashingPlanForm { interface WashingPlanForm {
productId: string; productId: string;
@ -41,84 +40,31 @@ export function NewWashingPlan() {
const fetchProducts = useCallback(async () => { const fetchProducts = useCallback(async () => {
if (!auth.company) return; if (!auth.company) return;
// 获取产品列表 const res = await purchaserApi.listProducts();
const { data: productsData } = await supabase const productsData = res.data || [];
.from('products')
.select('*')
.eq('company_id', auth.company.id)
.order('created_at', { ascending: false });
// 获取生产计划(关联到产品) const productsWithInventory: ProductWithInventory[] = productsData.map(product => ({
// 注意:使用 company_id 查询,与 RLS 策略保持一致 ...product,
const fabricCodes = (productsData || []).map(p => p.fabric_code); raw_fabric_meters: product.raw_fabric_meters || 0,
const { data: plansData, error: plansError } = await supabase raw_fabric_rolls: product.raw_fabric_rolls || 0,
.from('production_plans') total_stock: product.total_stock || 0,
.select('id, fabric_code, purchaser_id') total_in_meters: 0,
.in('fabric_code', fabricCodes); total_in_rolls: 0,
total_out_meters: 0,
if (plansError) { total_out_rolls: 0
console.error('获取生产计划失败:', plansError); }));
}
// 过滤出当前采购商的计划(客户端过滤,避免 RLS 问题)
const filteredPlansData = (plansData || []).filter(plan => plan.purchaser_id === auth.company?.id);
// 获取入库记录(用于计算实际米数和匹数)
const planIds = (filteredPlansData || []).map(p => p.id);
const { data: inventoryRecords } = await supabase
.from('inventory_records')
.select('plan_id, quantity, rolls')
.in('plan_id', planIds);
// 按 plan_id 汇总实际入库数据
const planInventoryMap: Record<string, { meters: number; rolls: number }> = {};
(inventoryRecords || []).forEach((r: any) => {
if (!planInventoryMap[r.plan_id]) {
planInventoryMap[r.plan_id] = { meters: 0, rolls: 0 };
}
planInventoryMap[r.plan_id].meters += Number(r.quantity) || 0;
planInventoryMap[r.plan_id].rolls += Number(r.rolls) || 0;
});
// 按 fabric_code 汇总库存
const fabricCodeInventory: Record<string, { meters: number; rolls: number }> = {};
(filteredPlansData || []).forEach((plan: any) => {
if (!fabricCodeInventory[plan.fabric_code]) {
fabricCodeInventory[plan.fabric_code] = { meters: 0, rolls: 0 };
}
const planInventory = planInventoryMap[plan.id] || { meters: 0, rolls: 0 };
fabricCodeInventory[plan.fabric_code].meters += planInventory.meters;
fabricCodeInventory[plan.fabric_code].rolls += planInventory.rolls;
});
// 计算每个产品的库存
const productsWithInventory: ProductWithInventory[] = (productsData || []).map(product => {
const inventory = fabricCodeInventory[product.fabric_code] || { meters: 0, rolls: 0 };
return {
...product,
raw_fabric_meters: inventory.meters,
raw_fabric_rolls: inventory.rolls,
total_stock: inventory.meters,
total_in_meters: inventory.meters,
total_in_rolls: inventory.rolls,
total_out_meters: 0,
total_out_rolls: 0
};
});
setProducts(productsWithInventory); setProducts(productsWithInventory);
}, [auth.company]); }, [auth.company]);
// 获取水洗厂列表 // 获取水洗厂列表
const fetchWashingFactories = useCallback(async () => { const fetchWashingFactories = useCallback(async () => {
const { data } = await supabase try {
.from('companies') const res = await http.get<Company[]>('/purchaser/factories', { role: 'washing' });
.select('*') setWashingFactories(res.data || []);
.eq('role', 'washing') } catch {
.order('name', { ascending: true }); setWashingFactories([]);
setWashingFactories(data || []); }
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -185,68 +131,22 @@ export function NewWashingPlan() {
setSubmitting(true); setSubmitting(true);
// 生成计划编号 try {
const planCode = generatePlanCode(); await purchaserApi.createWashingPlan({
product_id: form.productId,
washing_factory_id: form.washingFactoryId,
planned_meters: parseFloat(form.plannedMeters),
washing_price: parseFloat(form.washingPrice),
estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0,
notes: form.notes || undefined
} as any);
// 1. 创建水洗计划
const { data: washingPlan, error: insertError } = await supabase.from('washing_plans').insert({
plan_code: planCode,
company_id: auth.company!.id,
product_id: form.productId,
washing_factory_id: form.washingFactoryId,
planned_meters: plannedMeters,
washing_price: parseFloat(form.washingPrice),
estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0,
notes: form.notes || null,
created_by: auth.user?.id,
status: WASHING_PLAN_STATUS.PENDING
}).select().single();
if (insertError) {
setSubmitting(false); setSubmitting(false);
setError('创建失败: ' + insertError.message); navigate('/purchaser');
return; } catch (err: any) {
}
// 2. 自动出库 - 创建出库记录
// 计算需要出库的匹数(按比例计算)
const stockMeters = selectedProduct.total_stock || 0;
const stockRolls = selectedProduct.raw_fabric_rolls || 0;
const outboundRolls = stockMeters > 0 ? Math.ceil((plannedMeters / stockMeters) * stockRolls) : 0;
const { error: outboundError } = await supabase.from('product_outbound_records').insert({
company_id: auth.company!.id,
product_id: form.productId,
batch_no: `WS-${planCode}`,
rolls: outboundRolls,
meters: plannedMeters,
notes: `水洗计划自动出库: ${planCode}`,
operator_id: auth.user?.id || null,
washing_plan_id: washingPlan.id,
outbound_type: 'auto_washing'
});
if (outboundError) {
setSubmitting(false); setSubmitting(false);
setError('出库记录创建失败: ' + outboundError.message); setError('创建失败: ' + (err.message || '未知错误'));
return;
} }
// 3. 更新产品库存
const { error: updateError } = await supabase.from('products').update({
raw_fabric_rolls: Math.max(0, (selectedProduct.raw_fabric_rolls || 0) - outboundRolls),
raw_fabric_meters: Math.max(0, (selectedProduct.raw_fabric_meters || 0) - plannedMeters),
total_stock: Math.max(0, (selectedProduct.total_stock || 0) - plannedMeters)
}).eq('id', form.productId);
if (updateError) {
setSubmitting(false);
setError('库存更新失败: ' + updateError.message);
return;
}
setSubmitting(false);
navigate('/purchaser');
}; };
const selectedProduct = products.find(p => p.id === form.productId); const selectedProduct = products.find(p => p.id === form.productId);

View File

@ -1,7 +1,7 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { purchaserApi, http } from '../../api';
import { useInventoryData } from '../../hooks/useInventoryData'; import { useInventoryData } from '../../hooks/useInventoryData';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import { useErrorHandler } from '../../hooks/useErrorHandler'; import { useErrorHandler } from '../../hooks/useErrorHandler';
@ -158,7 +158,6 @@ export function WarehouseManage() {
if (!auth.company) return; if (!auth.company) return;
const productData = { const productData = {
company_id: auth.company.id,
product_name: formData.product_name, product_name: formData.product_name,
weight: parseFloat(formData.total_weight) || parseFloat(formData.weight) || 0, weight: parseFloat(formData.total_weight) || parseFloat(formData.weight) || 0,
warp_weight: parseFloat(formData.warp_weight) || null, warp_weight: parseFloat(formData.warp_weight) || null,
@ -171,116 +170,40 @@ export function WarehouseManage() {
image_url: formData.image_url || null image_url: formData.image_url || null
}; };
// 校验1产品码全局唯一 const yarnRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
const { data: existingFabricCode } = await supabase
.from('products')
.select('id, product_name, color')
.eq('company_id', auth.company.id)
.eq('fabric_code', productData.fabric_code)
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
.maybeSingle();
if (existingFabricCode) { try {
handleError(new Error('产品码重复'), { userMessage: `产品码 "${productData.fabric_code}" 已存在,请使用其他产品码` }); if (editingProduct) {
return; await http.put(`/purchaser/products/${editingProduct.id}`, {
} ...productData,
yarn_ratios: yarnRatios
// 校验2色号在"成品名称-克重-颜色"组合中唯一 });
// 即:同一个"成品名称-克重-颜色"下,色号不能重复 } else {
const { data: existingColorCode } = await supabase await http.post('/purchaser/products', {
.from('products') ...productData,
.select('id, fabric_code') yarn_ratios: yarnRatios
.eq('company_id', auth.company.id)
.eq('product_name', productData.product_name)
.eq('total_weight', productData.total_weight || 0)
.eq('color', productData.color)
.eq('color_code', productData.color_code)
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
.maybeSingle();
if (existingColorCode) {
handleError(new Error('色号重复'), { userMessage: `"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号` });
return;
}
let productId: string;
if (editingProduct) {
// 更新产品
const oldPrice = editingProduct.production_price || 0;
const newPrice = parseFloat(formData.production_price) || 0;
const { error } = await supabase
.from('products')
.update(productData)
.eq('id', editingProduct.id);
if (error) {
handleError(error, { userMessage: '更新失败' });
return;
}
// 记录价格变更
if (oldPrice !== newPrice) {
await supabase.from('product_price_history').insert({
product_id: editingProduct.id,
company_id: auth.company.id,
old_price: oldPrice,
new_price: newPrice,
change_amount: newPrice - oldPrice,
operator_id: auth.user?.id,
operator_name: auth.user?.username || '',
change_reason: '产品信息编辑'
}); });
} }
productId = editingProduct.id; setShowProductModal(false);
await supabase.from('product_yarn_ratios').delete().eq('product_id', productId); setEditingProduct(null);
} else { setProductForm(initialProductForm);
// 创建产品 await refetch();
const { data, error } = await supabase } catch (err) {
.from('products') handleError(err, { userMessage: editingProduct ? '更新失败' : '创建失败' });
.insert(productData)
.select()
.single();
if (error) {
handleError(error, { userMessage: '创建失败' });
return;
}
productId = data.id;
} }
// 保存纱线配比
const validRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
if (validRatios.length > 0) {
await supabase.from('product_yarn_ratios').insert(
validRatios.map(r => ({
product_id: productId,
yarn_name: r.yarn_name,
ratio: r.ratio,
yarn_type: r.yarn_type || 'all'
}))
);
}
// 重置并刷新
setShowProductModal(false);
setEditingProduct(null);
setProductForm(initialProductForm);
await refetch();
}, [auth.company, auth.user, editingProduct, refetch]); }, [auth.company, auth.user, editingProduct, refetch]);
// 删除产品 // 删除产品
const handleDeleteProduct = useCallback(async (id: string) => { const handleDeleteProduct = useCallback(async (id: string) => {
if (!confirm('确定要删除这个产品吗?')) return; if (!confirm('确定要删除这个产品吗?')) return;
const { error } = await supabase.from('products').delete().eq('id', id); try {
if (error) { await purchaserApi.deleteProduct(id);
handleError(error, { userMessage: '删除失败' }); await refetch();
return; } catch (err) {
handleError(err, { userMessage: '删除失败' });
} }
await refetch();
}, [refetch]); }, [refetch]);
// 编辑产品 // 编辑产品
@ -321,15 +244,16 @@ export function WarehouseManage() {
// 查看价格历史 // 查看价格历史
const handleViewPriceHistory = useCallback(async (product: ProductWithInventory) => { const handleViewPriceHistory = useCallback(async (product: ProductWithInventory) => {
const { data } = await supabase try {
.from('product_price_history') const data = await purchaserApi.priceHistory(product.id);
.select('*') setSelectedPriceHistory(data || []);
.eq('product_id', product.id) setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
.order('created_at', { ascending: false }); setPriceHistoryModalOpen(true);
} catch {
setSelectedPriceHistory(data || []); setSelectedPriceHistory([]);
setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`); setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
setPriceHistoryModalOpen(true); setPriceHistoryModalOpen(true);
}
}, []); }, []);
// 查看出入库历史 - 合并入库和出库记录 // 查看出入库历史 - 合并入库和出库记录
@ -351,41 +275,26 @@ export function WarehouseManage() {
const handleInSubmit = useCallback(async () => { const handleInSubmit = useCallback(async () => {
if (!auth.company || !inForm.product_id || !inForm.batch_no) return; if (!auth.company || !inForm.product_id || !inForm.batch_no) return;
const { error } = await supabase.from('product_inventory_records').insert({ try {
company_id: auth.company.id, await purchaserApi.inbound(inForm.product_id, {
product_id: inForm.product_id, batch_no: inForm.batch_no,
batch_no: inForm.batch_no, rolls: inForm.rolls,
rolls: inForm.rolls, meters: inForm.meters,
meters: inForm.meters, notes: inForm.notes || undefined
notes: inForm.notes || null, });
operator_id: auth.user?.id || null
});
if (error) { setShowInModal(false);
handleError(error, { userMessage: '入库失败' }); setInForm(initialInboundForm);
return; await refetch();
} catch (err) {
handleError(err, { userMessage: '入库失败' });
} }
// 更新产品库存
const product = products.find(p => p.id === inForm.product_id);
if (product) {
await supabase.from('products').update({
raw_fabric_rolls: (product.raw_fabric_rolls || 0) + inForm.rolls,
raw_fabric_meters: (product.raw_fabric_meters || 0) + inForm.meters,
total_stock: (product.total_stock || 0) + inForm.meters
}).eq('id', inForm.product_id);
}
setShowInModal(false);
setInForm(initialInboundForm);
await refetch();
}, [auth.company, auth.user, inForm, products, refetch]); }, [auth.company, auth.user, inForm, products, refetch]);
// 提交出库 // 提交出库
const handleOutSubmit = useCallback(async () => { const handleOutSubmit = useCallback(async () => {
if (!auth.company || !outForm.product_id || !outForm.batch_no || !outForm.source_batch_id || !outForm.recipient_company_id) return; if (!auth.company || !outForm.product_id || !outForm.batch_no || !outForm.source_batch_id || !outForm.recipient_company_id) return;
// 检查库存是否充足
const product = products.find(p => p.id === outForm.product_id); const product = products.find(p => p.id === outForm.product_id);
if (!product) { if (!product) {
handleError(new Error('产品不存在'), { userMessage: '产品不存在' }); handleError(new Error('产品不存在'), { userMessage: '产品不存在' });
@ -400,35 +309,22 @@ export function WarehouseManage() {
return; return;
} }
const { error } = await supabase.from('product_outbound_records').insert({ try {
company_id: auth.company.id, await purchaserApi.outbound(outForm.product_id, {
product_id: outForm.product_id, batch_no: outForm.batch_no,
batch_no: outForm.batch_no, rolls: outForm.rolls,
rolls: outForm.rolls, meters: outForm.meters,
meters: outForm.meters, outbound_type: 'manual',
notes: outForm.notes || null, notes: outForm.notes || undefined,
operator_id: auth.user?.id || null, recipient_company_id: outForm.recipient_company_id
outbound_type: 'manual', });
source_batch_id: outForm.source_batch_id,
recipient_company_id: outForm.recipient_company_id,
recipient_company_name: outForm.recipient_company_name
});
if (error) { setShowOutModal(false);
handleError(error, { userMessage: '出库失败' }); setOutForm(initialOutboundForm);
return; await refetch();
} catch (err) {
handleError(err, { userMessage: '出库失败' });
} }
// 更新产品库存
await supabase.from('products').update({
raw_fabric_rolls: Math.max(0, (product.raw_fabric_rolls || 0) - outForm.rolls),
raw_fabric_meters: Math.max(0, (product.raw_fabric_meters || 0) - outForm.meters),
total_stock: Math.max(0, (product.total_stock || 0) - outForm.meters)
}).eq('id', outForm.product_id);
setShowOutModal(false);
setOutForm(initialOutboundForm);
await refetch();
}, [auth.company, auth.user, outForm, products, refetch]); }, [auth.company, auth.user, outForm, products, refetch]);
// 打开新建产品模态框 // 打开新建产品模态框

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { textileApi } from '../../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react'; import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
@ -72,41 +72,20 @@ export function TextileDashboard() {
}, [auth.company]); }, [auth.company]);
const fetchPlans = async () => { const fetchPlans = async () => {
const { data: planFactories } = await supabase const response = await textileApi.listPlans();
.from('plan_factories') const planData = response.data || [];
.select('plan_id')
.eq('factory_id', auth.company!.id)
.eq('factory_type', 'textile');
if (!planFactories || planFactories.length === 0) {
setPlans([]);
setLoading(false);
return;
}
const planIds = planFactories.map(pf => pf.plan_id);
const { data: planData } = await supabase
.from('production_plans')
.select('*')
.in('id', planIds)
.order('created_at', { ascending: false });
// 获取流程步骤,用于检测 rejected 状态
const { data: stepsData } = await supabase
.from('plan_process_steps')
.select('plan_id, status')
.in('plan_id', planIds);
// 标记有 rejected 步骤的计划
const rejectedIds = new Set<string>(); const rejectedIds = new Set<string>();
(stepsData || []).forEach(step => { planData.forEach(plan => {
if (step.status === STEP_STATUS.REJECTED) { (plan.process_steps || []).forEach(step => {
rejectedIds.add(step.plan_id); if (step.status === STEP_STATUS.REJECTED) {
} rejectedIds.add(plan.id);
}
});
}); });
setRejectedPlanIds(rejectedIds); setRejectedPlanIds(rejectedIds);
setPlans(planData || []); setPlans(planData);
setLoading(false); setLoading(false);
}; };

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { ArrowLeft, Package, ChevronDown, ChevronUp, List } from 'lucide-react'; import { ArrowLeft, Package, ChevronDown, ChevronUp, List } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { textileApi, http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { InventoryRecordsModal } from '../../components/InventoryRecordsModal'; import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
@ -93,125 +93,68 @@ export function FabricWarehouse() {
useEffect(() => { useEffect(() => {
if (!auth.company) return; if (!auth.company) return;
fetchFabricInventory(); fetchFabricInventory();
// 订阅入库记录变化,实现实时更新
const subscription = supabase
.channel('inventory_records_changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'inventory_records'
},
() => {
// 有入库记录变化时刷新数据
fetchFabricInventory();
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [auth.company]); }, [auth.company]);
const fetchFabricInventory = async () => { const fetchFabricInventory = async () => {
setLoading(true); setLoading(true);
// 获取纺织厂关联的所有计划
const { data: planFactories } = await supabase
.from('plan_factories')
.select('plan_id')
.eq('factory_id', auth.company!.id)
.eq('factory_type', 'textile');
if (!planFactories || planFactories.length === 0) { try {
setInventory([]); const response = await textileApi.listPlans();
const plansData = response.data || [];
if (plansData.length === 0) {
setInventory(demoInventory);
setLoading(false);
return;
}
const planIds = plansData.map(p => p.id);
const purchaserIds = [...new Set(plansData.map(p => p.purchaser_id))];
const [productsRes, ...inventoryResults] = await Promise.all([
http.get<any[]>('/common/products', { company_ids: purchaserIds.join(',') }),
...planIds.map(id => textileApi.listInventory(id).catch(() => []))
]);
const productWeightMap: Record<string, number> = {};
(productsRes.data || []).forEach((p: any) => {
productWeightMap[p.fabric_code] = p.weight;
});
const recordsByPlan: Record<string, { time: string; quantity: number; rolls: number }[]> = {};
planIds.forEach((id, index) => {
const records = inventoryResults[index] as any[] || [];
recordsByPlan[id] = records.map((r: any) => ({
time: r.created_at,
quantity: Number(r.quantity) || 0,
rolls: Number(r.rolls) || 0
}));
});
const inventoryList: FabricInventory[] = plansData.map(plan => {
const records = recordsByPlan[plan.id] || [];
const totalInFromRecords = records.reduce((sum, r) => sum + r.quantity, 0);
const completedQty = totalInFromRecords > 0 ? totalInFromRecords : (plan.completed_quantity || 0);
return {
id: plan.id,
plan_id: plan.id,
fabric_code: plan.fabric_code,
plan_code: plan.plan_code,
weight: productWeightMap[plan.fabric_code] || 0,
target_quantity: plan.target_quantity,
completed_quantity: completedQty,
in_records: records
};
});
setInventory(inventoryList.length > 0 ? inventoryList : demoInventory);
} catch {
setInventory(demoInventory);
}
setLoading(false); setLoading(false);
return;
}
const planIds = planFactories.map(pf => pf.plan_id);
// 获取计划对应的产品信息
const { data: plansData } = await supabase
.from('production_plans')
.select('id, plan_code, fabric_code, target_quantity, completed_quantity, purchaser_id')
.in('id', planIds);
if (!plansData || plansData.length === 0) {
setInventory([]);
setLoading(false);
return;
}
// 获取采购商的产品信息(克重)
const purchaserIds = [...new Set(plansData.map(p => p.purchaser_id))];
const fabricCodes = plansData.map(p => p.fabric_code);
const { data: productsData } = await supabase
.from('products')
.select('fabric_code, weight, company_id')
.in('company_id', purchaserIds)
.in('fabric_code', fabricCodes);
const productWeightMap: Record<string, number> = {};
productsData?.forEach(p => {
productWeightMap[p.fabric_code] = p.weight;
});
// 获取入库记录 - 从 inventory_records 表查询
const { data: inventoryRecords } = await supabase
.from('inventory_records')
.select('plan_id, quantity, rolls, created_at')
.in('plan_id', planIds)
.order('created_at', { ascending: false });
// 按 plan_id 分组入库记录
const recordsByPlan: Record<string, { time: string; quantity: number; rolls: number }[]> = {};
inventoryRecords?.forEach(record => {
if (!recordsByPlan[record.plan_id]) {
recordsByPlan[record.plan_id] = [];
}
recordsByPlan[record.plan_id].push({
time: record.created_at,
quantity: Number(record.quantity) || 0,
rolls: Number(record.rolls) || 0
});
});
// 构建库存数据 - 每个计划一条记录
// 以 inventory_records 实际入库记录为准,计算准确的已完成数量
const inventoryList: FabricInventory[] = plansData.map(plan => {
const records = recordsByPlan[plan.id] || [];
// 从实际入库记录计算总米数和总匹/件数
const totalInFromRecords = records.reduce((sum, r) => sum + r.quantity, 0);
const totalRollsFromRecords = records.reduce((sum, r) => sum + r.rolls, 0);
// 使用实际入库记录的总和作为已完成数量(更准确)
const completedQty = totalInFromRecords > 0 ? totalInFromRecords : (plan.completed_quantity || 0);
return {
id: plan.id,
plan_id: plan.id,
fabric_code: plan.fabric_code,
plan_code: plan.plan_code,
weight: productWeightMap[plan.fabric_code] || 0,
target_quantity: plan.target_quantity,
completed_quantity: completedQty, // 使用实际入库记录的总和
in_records: records
};
});
setInventory(inventoryList);
// 如果没有真实数据,使用示例数据
if (inventoryList.length === 0) {
setInventory(demoInventory);
}
setLoading(false);
}; };
const toggleExpand = (planId: string) => { const toggleExpand = (planId: string) => {

View File

@ -3,9 +3,8 @@ import { motion } from 'framer-motion';
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react'; import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { textileApi } from '../../api';
import type { Payment, ProductionPlan } from '../../types'; import type { Payment, ProductionPlan } from '../../types';
import { PAYMENT_STATUS } from '../../utils/constants';
export function PaymentPending() { export function PaymentPending() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -21,29 +20,20 @@ export function PaymentPending() {
}, [auth.company]); }, [auth.company]);
const fetchPayments = async () => { const fetchPayments = async () => {
// 获取当前纺织厂作为收款方的待结款 const response = await textileApi.listPayments();
const { data: paymentData } = await supabase const paymentData = response.data || [];
.from('payments')
.select('*')
.eq('to_company_id', auth.company!.id)
.eq('status', PAYMENT_STATUS.PENDING);
if (!paymentData || paymentData.length === 0) { if (paymentData.length === 0) {
setPayments([]); setPayments([]);
setPlanMap({}); setPlanMap({});
setLoading(false); setLoading(false);
return; return;
} }
// 获取关联的计划信息
const planIds = paymentData.map(p => p.plan_id);
const { data: planData } = await supabase
.from('production_plans')
.select('*')
.in('id', planIds);
const map: Record<string, ProductionPlan> = {}; const map: Record<string, ProductionPlan> = {};
(planData || []).forEach(p => { map[p.id] = p; }); paymentData.forEach((p: any) => {
if (p.plan) map[p.plan_id] = p.plan;
});
setPayments(paymentData); setPayments(paymentData);
setPlanMap(map); setPlanMap(map);
@ -54,19 +44,10 @@ export function PaymentPending() {
const handleConfirmPay = async (paymentId: string) => { const handleConfirmPay = async (paymentId: string) => {
setConfirming(paymentId); setConfirming(paymentId);
const now = new Date().toISOString(); try {
const { error } = await supabase await textileApi.confirmPayment(paymentId);
.from('payments')
.update({
status: PAYMENT_STATUS.COMPLETED,
paid_at: now
})
.eq('id', paymentId);
if (error) {
console.error('结款失败:', error);
} else {
await fetchPayments(); await fetchPayments();
} catch {
} }
setConfirming(null); setConfirming(null);
}; };

View File

@ -1,13 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { textileApi, http } from '../../api';
import { usePlanStatusSync } from '../../hooks/usePlanStatusSync'; import { usePlanStatusSync } from '../../hooks/usePlanStatusSync';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react'; import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react';
import { ProcessFlow } from '../../components/ProcessFlow'; import { ProcessFlow } from '../../components/ProcessFlow';
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications'; import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
import { getSupabaseUrl } from '../../supabase/client';
import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants'; import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
import { extractParamsFromLink } from '../../utils/helpers'; import { extractParamsFromLink } from '../../utils/helpers';
import { STORAGE_KEYS } from '../../config/app'; import { STORAGE_KEYS } from '../../config/app';
@ -59,148 +58,73 @@ export function TextilePlanOverview() {
useEffect(() => { useEffect(() => {
if (!auth.company) return; if (!auth.company) return;
fetchPlans(); fetchPlans();
// 订阅入库记录变化,实现实时更新
const subscription = supabase
.channel('plan_overview_inventory_changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'inventory_records'
},
() => {
// 有入库记录变化时刷新数据
fetchPlans();
}
)
// 订阅 plan_factories 变化,导入新计划后自动刷新
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'plan_factories'
},
(payload) => {
// 仅当关联到当前纺织厂时刷新
if (payload.new && (payload.new as any).factory_id === auth.company!.id) {
fetchPlans();
}
// 删除关联时也刷新
if (payload.eventType === 'DELETE') {
fetchPlans();
}
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [auth.company]); }, [auth.company]);
const fetchPlans = async () => { const fetchPlans = async () => {
// 获取关联到当前纺织厂的计划 const response = await textileApi.listPlans();
const { data: planFactories } = await supabase const planData = response.data || [];
.from('plan_factories')
.select('plan_id')
.eq('factory_id', auth.company!.id)
.eq('factory_type', 'textile');
if (!planFactories || planFactories.length === 0) { if (planData.length === 0) {
setGroups([]); setGroups([]);
setLoading(false); setLoading(false);
return; return;
} }
const planIds = planFactories.map(pf => pf.plan_id); const planIds = planData.map(p => p.id);
// 获取计划详情 const operatorIds = [...new Set(
const { data: planData } = await supabase planData.flatMap(p => (p.process_steps || []).filter(s => s.operator_id).map(s => s.operator_id!))
.from('production_plans') )];
.select('*')
.in('id', planIds)
.order('created_at', { ascending: false });
if (!planData || planData.length === 0) {
setGroups([]);
setLoading(false);
return;
}
// 获取所有流程步骤
const { data: stepsData } = await supabase
.from('plan_process_steps')
.select('*')
.in('plan_id', planIds);
// 获取操作人信息
const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[];
if (operatorIds.length > 0) { if (operatorIds.length > 0) {
const { data: profilesData } = await supabase try {
.from('profiles') const { data: profilesData } = await http.get<any[]>('/common/profiles', { ids: operatorIds.join(',') });
.select('id, username') const names: Record<string, string> = {};
.in('id', operatorIds); (profilesData || []).forEach(p => { names[p.id] = p.username; });
setOperatorNames(names);
const names: Record<string, string> = {}; } catch {}
(profilesData || []).forEach(p => {
names[p.id] = p.username;
});
setOperatorNames(names);
} }
// 获取采购商公司信息
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))]; const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
const { data: companiesData } = await supabase
.from('companies')
.select('*')
.in('id', purchaserIds);
const companyMap: Record<string, string> = {}; const companyMap: Record<string, string> = {};
(companiesData || []).forEach(c => { companyMap[c.id] = c.name; }); planData.forEach(p => {
if (p.purchaser_name) companyMap[p.purchaser_id] = p.purchaser_name;
});
// 获取所有计划的入库记录 const inventoryResults = await Promise.all(
const { data: inventoryData } = await supabase planIds.map(id => textileApi.listInventory(id).catch(() => [] as any[]))
.from('inventory_records') );
.select('plan_id, quantity, rolls, created_at') const inventoryByPlan: Record<string, any[]> = {};
.in('plan_id', planIds) planIds.forEach((id, idx) => { inventoryByPlan[id] = inventoryResults[idx] || []; });
.order('created_at', { ascending: false });
// 获取纱线配比 let yarnRatiosData: any[] = [];
const { data: yarnRatiosData } = await supabase let productsData: any[] = [];
.from('yarn_ratios') try {
.select('*') const [yrRes, prRes] = await Promise.all([
.in('plan_id', planIds); http.get<any[]>('/textile/yarn-ratios', { plan_ids: planIds.join(',') }),
http.get<any[]>('/common/products', { company_ids: purchaserIds.join(',') })
]);
yarnRatiosData = yrRes.data || [];
productsData = prRes.data || [];
} catch {}
// 获取产品信息(克重、图片等)
const { data: productsData } = await supabase
.from('products')
.select('*')
.in('company_id', purchaserIds);
// 按采购商分组
const grouped: Record<string, PlanGroup> = {}; const grouped: Record<string, PlanGroup> = {};
planData.forEach(plan => { planData.forEach(plan => {
const pid = plan.purchaser_id; const pid = plan.purchaser_id;
if (!grouped[pid]) { if (!grouped[pid]) {
grouped[pid] = { grouped[pid] = {
purchaserId: pid, purchaserId: pid,
purchaserName: companyMap[pid] || '未知布行', purchaserName: companyMap[pid] || plan.purchaser_name || '未知布行',
plans: [] plans: []
}; };
} }
const processSteps = (stepsData || []) const processSteps = (plan.process_steps || [])
.filter(s => s.plan_id === plan.id)
.sort((a, b) => { .sort((a, b) => {
const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse']; const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
return order.indexOf(a.step_type) - order.indexOf(b.step_type); return order.indexOf(a.step_type) - order.indexOf(b.step_type);
}); });
const inventoryRecords = (inventoryData || []) const inventoryRecords = (inventoryByPlan[plan.id] || [])
.filter(r => r.plan_id === plan.id) .map((r: any) => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls })); const yarnRatios = (yarnRatiosData)
const yarnRatios = (yarnRatiosData || [])
.filter(y => y.plan_id === plan.id) .filter(y => y.plan_id === plan.id)
.map(y => ({ .map(y => ({
id: y.id, id: y.id,
@ -213,8 +137,7 @@ export function TextilePlanOverview() {
created_at: y.created_at, created_at: y.created_at,
company_id: y.company_id company_id: y.company_id
})); }));
// 查找关联的产品信息 const product = productsData.find(p =>
const product = (productsData || []).find(p =>
p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id
); );
grouped[pid].plans.push({ grouped[pid].plans.push({
@ -232,7 +155,6 @@ export function TextilePlanOverview() {
const groupList = Object.values(grouped); const groupList = Object.values(grouped);
setGroups(groupList); setGroups(groupList);
// 默认展开第一个组
if (groupList.length > 0) { if (groupList.length > 0) {
setExpandedGroups(new Set([groupList[0].purchaserId])); setExpandedGroups(new Set([groupList[0].purchaserId]));
} }
@ -310,54 +232,31 @@ export function TextilePlanOverview() {
} }
setRejecting(rejectStepId); setRejecting(rejectStepId);
const now = new Date().toISOString();
try { try {
// 获取步骤信息 const planId = groups.flatMap(g => g.plans).find(
const { data: step } = await supabase p => p.processSteps.some(s => s.id === rejectStepId)
.from('plan_process_steps') )?.id;
.select('plan_id')
.eq('id', rejectStepId)
.maybeSingle();
if (!step?.plan_id) { if (!planId) {
setRejecting(null); setRejecting(null);
handleCloseRejectModal(); handleCloseRejectModal();
return; return;
} }
// 更新步骤状态为拒绝(使用 notes 字段记录拒绝原因) await textileApi.rejectStep(planId, rejectStepId, { reason: rejectReason.trim() });
const { error } = await supabase
.from('plan_process_steps')
.update({
status: STEP_STATUS.REJECTED,
notes: `拒绝原因: ${rejectReason.trim()}`,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', rejectStepId);
if (error) { await notifyPlanUpdated(planId, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id);
handleError(error, { userMessage: '拒绝失败,请重试' });
setRejecting(null);
return;
}
// 发送拒绝通知给采购商 setRejectedPlans(prev => new Set([...prev, planId]));
await notifyPlanUpdated(step.plan_id, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id);
// 将该计划标记为已拒绝(本地状态,显示灰色)
setRejectedPlans(prev => new Set([...prev, step.plan_id]));
// 存储到 localStorage让 Dashboard 也能过滤掉
const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans); const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans);
const rejectedArray = stored ? JSON.parse(stored) : []; const rejectedArray = stored ? JSON.parse(stored) : [];
if (!rejectedArray.includes(step.plan_id)) { if (!rejectedArray.includes(planId)) {
rejectedArray.push(step.plan_id); rejectedArray.push(planId);
localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray)); localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray));
} }
// 关闭弹窗并收起计划卡片
handleCloseRejectModal(); handleCloseRejectModal();
setExpandedPlan(null); setExpandedPlan(null);
} catch (err) { } catch (err) {
@ -368,248 +267,54 @@ export function TextilePlanOverview() {
const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => { const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => {
setConfirming(stepId); setConfirming(stepId);
const now = new Date().toISOString();
// 获取步骤信息 const plan = groups.flatMap(g => g.plans).find(
const { data: step } = await supabase p => p.processSteps.some(s => s.id === stepId)
.from('plan_process_steps') );
.select('step_type, plan_id') const step = plan?.processSteps.find(s => s.id === stepId);
.eq('id', stepId) const planId = plan?.id;
.maybeSingle();
const planId = step?.plan_id; if (!planId || !step) {
if (!planId) {
setConfirming(null); setConfirming(null);
return; return;
} }
// 如果是坯布入库,使用 Edge Function 进行事务处理 try {
if (step?.step_type === 'fabric_warehouse' && quantity && quantity > 0) { if (step.step_type === 'fabric_warehouse' && quantity && quantity > 0) {
// 获取纺织厂的仓库和工厂信息 await textileApi.inbound(planId, {
const { data: warehouse } = await supabase quantity,
.from('warehouses') rolls: rolls || 0,
.select('id')
.eq('company_id', auth.company!.id)
.eq('type', 'fabric')
.maybeSingle();
// 获取工厂信息(地址或名称)
const { data: factoryInfo } = await supabase
.from('companies')
.select('name, address')
.eq('id', auth.company!.id)
.maybeSingle();
// 仓库位置:优先使用地址,没有则使用工厂名称
const warehouseLocation = factoryInfo?.address || factoryInfo?.name || '';
// 获取计划信息包括采购商ID和采购价
const { data: plan } = await supabase
.from('production_plans')
.select('completed_quantity, target_quantity, purchaser_id, production_price')
.eq('id', planId)
.maybeSingle();
if (plan) {
const newCompletedQuantity = plan.completed_quantity + quantity;
const progressPercent = (newCompletedQuantity / plan.target_quantity) * 100;
const pricePerMeter = plan.production_price || 0;
const amount = quantity * pricePerMeter;
// 使用 Edge Function 进行入库事务处理
const session = (await supabase.auth.getSession()).data.session;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (session) {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
const response = await fetch(`${getSupabaseUrl()}/functions/v1/fabric-warehouse-inbound`, {
method: 'POST',
headers,
body: JSON.stringify({
planId: planId,
quantity: quantity,
rolls: rolls || 0,
warehouseId: warehouse?.id || null,
warehouseLocation: warehouseLocation,
operatorId: auth.user?.id,
pricePerMeter: pricePerMeter,
}),
}); });
const result = await response.json(); await textileApi.completeStep(planId, stepId, {
if (!response.ok) { operator_name: auth.user?.username
throw new Error(result.error || '入库操作失败'); });
}
const inventoryRecord = result.data?.inventoryRecord;
// 创建或更新应付账款记录
if (inventoryRecord) {
// 检查是否已存在应付账款记录
const { data: existingAP } = await supabase
.from('accounts_payable')
.select('*')
.eq('plan_id', planId)
.eq('textile_factory_id', auth.company!.id)
.maybeSingle();
if (existingAP) {
// 更新应付账款
await supabase
.from('accounts_payable')
.update({
total_quantity: existingAP.total_quantity + quantity,
total_amount: existingAP.total_amount + amount,
unpaid_quantity: existingAP.unpaid_quantity + quantity,
unpaid_amount: existingAP.unpaid_amount + amount,
updated_at: now
})
.eq('id', existingAP.id);
// 创建应付账款明细
await supabase
.from('accounts_payable_items')
.insert({
accounts_payable_id: existingAP.id,
inventory_record_id: inventoryRecord.id,
quantity: quantity,
rolls: rolls || 0,
price_per_meter: pricePerMeter,
amount: amount,
status: 'unpaid',
created_at: now
});
} else {
// 创建新的应付账款
const { data: newAP } = await supabase
.from('accounts_payable')
.insert({
plan_id: planId,
purchaser_id: plan.purchaser_id,
textile_factory_id: auth.company!.id,
total_quantity: quantity,
total_amount: amount,
unpaid_quantity: quantity,
unpaid_amount: amount,
price_per_meter: pricePerMeter,
status: 'unpaid',
created_at: now,
updated_at: now
})
.select()
.single();
if (newAP) {
// 创建应付账款明细
await supabase
.from('accounts_payable_items')
.insert({
accounts_payable_id: newAP.id,
inventory_record_id: inventoryRecord.id,
quantity: quantity,
rolls: rolls || 0,
price_per_meter: pricePerMeter,
amount: amount,
status: 'unpaid',
created_at: now
});
}
}
}
// 如果进度达到97%或以上,标记步骤为完成,否则标记为进行中
if (progressPercent >= 97) {
await supabase
.from('plan_process_steps')
.update({
status: STEP_STATUS.COMPLETED,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
} else {
// 进度未达到97%标记步骤为进行中active
await supabase
.from('plan_process_steps')
.update({
status: STEP_STATUS.ACTIVE,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
}
}
} else {
// 其他步骤直接更新状态
const { error } = await supabase
.from('plan_process_steps')
.update({
status: STEP_STATUS.COMPLETED,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
if (error) {
handleError(error, { userMessage: '确认失败,请重试' });
setConfirming(null);
return;
}
}
// 更新计划状态 - 无论步骤类型,只要确认成功就更新计划状态
if (step?.step_type === 'confirm') {
const { error: planUpdateError } = await supabase
.from('production_plans')
.update({ status: PLAN_STATUS.PRODUCING })
.eq('id', planId);
if (planUpdateError) {
handleError(planUpdateError, { userMessage: '步骤已确认,但更新计划状态失败,请刷新页面' });
} else { } else {
// 发送计划开始生产通知 await textileApi.completeStep(planId, stepId, {
await notifyPlanUpdated(planId, PLAN_STATUS.PRODUCING, auth.company!.id, auth.user!.id); operator_name: auth.user?.username
});
} }
const stepNames: Record<string, string> = {
confirm: '确认计划',
yarn_purchase: '采纱',
dyeing: '染纱',
machine_start: '上机',
fabric_warehouse: '坯布生产'
};
const stepName = stepNames[step.step_type || ''] || '生产节点';
await notifyStepCompleted(
planId,
stepName,
auth.company!.id,
auth.user!.id,
step.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined
);
await fetchPlans();
} catch (err) {
handleError(err, { userMessage: '确认失败,请重试' });
} }
// 检查是否所有步骤都已完成
const { data: allSteps } = await supabase
.from('plan_process_steps')
.select('status')
.eq('plan_id', planId);
const allCompleted = allSteps?.every(s => s.status === STEP_STATUS.COMPLETED);
if (allCompleted) {
await supabase
.from('production_plans')
.update({ status: PLAN_STATUS.COMPLETED })
.eq('id', planId);
// 发送计划完成通知
await notifyPlanUpdated(planId, PLAN_STATUS.COMPLETED, auth.company!.id, auth.user!.id);
}
// 发送节点完成通知
const stepNames: Record<string, string> = {
confirm: '确认计划',
yarn_purchase: '采纱',
dyeing: '染纱',
machine_start: '上机',
fabric_warehouse: '坯布生产'
};
const stepName = stepNames[step?.step_type || ''] || '生产节点';
await notifyStepCompleted(
planId,
stepName,
auth.company!.id,
auth.user!.id,
step?.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined
);
// 刷新数据
await fetchPlans();
setConfirming(null); setConfirming(null);
}; };

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { textileApi, http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import type { YarnStock, Warehouse } from '../../types'; import type { YarnStock, Warehouse } from '../../types';
@ -42,45 +42,25 @@ export function YarnWarehouse() {
}, [activeTab, auth.company]); }, [activeTab, auth.company]);
const fetchData = async () => { const fetchData = async () => {
const { data: yarnData } = await supabase const response = await textileApi.listYarnStock();
.from('yarn_stock') setYarns(response.data || []);
.select('*')
.eq('company_id', auth.company!.id)
.order('name');
const { data: whData } = await supabase const { data: whData } = await http.get<Warehouse[]>('/textile/warehouses', { type: 'yarn' });
.from('warehouses')
.select('*')
.eq('company_id', auth.company!.id)
.eq('type', 'yarn');
setYarns(yarnData || []);
setWarehouses(whData || []); setWarehouses(whData || []);
setLoading(false); setLoading(false);
}; };
const fetchRecords = async () => { const fetchRecords = async () => {
setLoading(true); setLoading(true);
const { data: recordsData } = await supabase const { data: recordsData } = await http.get<any[]>('/textile/yarn-stock/records');
.from('yarn_stock_records')
.select('*')
.eq('company_id', auth.company!.id)
.order('created_at', { ascending: false });
// 获取纱线名称 const yarnMap = new Map(yarns.map(y => [y.id, y.name]));
const yarnIds = recordsData?.map(r => r.yarn_stock_id).filter((id): id is string => !!id) || [];
const { data: yarnsData } = await supabase
.from('yarn_stock')
.select('id, name')
.in('id', yarnIds.length > 0 ? yarnIds : ['00000000-0000-0000-0000-000000000000']);
const yarnMap = new Map(yarnsData?.map(y => [y.id, y.name]) || []); const recordsWithNames: YarnStockRecord[] = (recordsData || []).map(r => ({
const recordsWithNames: YarnStockRecord[] = recordsData?.map(r => ({
...r, ...r,
yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || '未知纱线' : '未知纱线', yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || r.yarn_name || '未知纱线' : '未知纱线',
record_type: r.record_type as 'inbound' | 'outbound' record_type: r.record_type as 'inbound' | 'outbound'
})) || []; }));
setRecords(recordsWithNames); setRecords(recordsWithNames);
setLoading(false); setLoading(false);
@ -88,16 +68,10 @@ export function YarnWarehouse() {
const handleAddYarn = async () => { const handleAddYarn = async () => {
if (!newYarn.name) return; if (!newYarn.name) return;
const yarnWarehouse = warehouses[0]; // 使用第一个纱线仓库 const yarnWarehouse = warehouses[0];
// 获取当前用户ID try {
const { data: { user } } = await supabase.auth.getUser(); const newYarnData = await textileApi.createYarnStock({
const operatorId = user?.id;
// 插入纱线库存
const { data: newYarnData, error } = await supabase
.from('yarn_stock')
.insert({
company_id: auth.company!.id, company_id: auth.company!.id,
warehouse_id: yarnWarehouse?.id || null, warehouse_id: yarnWarehouse?.id || null,
name: newYarn.name, name: newYarn.name,
@ -105,27 +79,18 @@ export function YarnWarehouse() {
quantity: newYarn.quantity, quantity: newYarn.quantity,
min_stock: newYarn.minStock, min_stock: newYarn.minStock,
unit: newYarn.unit unit: newYarn.unit
}) } as any);
.select()
.single();
if (error) { await textileApi.addYarnRecord(newYarnData.id, {
console.error('添加失败:', error); change_type: 'inbound',
} else {
// 记录入库
await supabase.from('yarn_stock_records').insert({
company_id: auth.company!.id,
yarn_stock_id: newYarnData.id,
record_type: 'inbound',
quantity: newYarn.quantity, quantity: newYarn.quantity,
unit: newYarn.unit, notes: `纱线入库 - ${newYarn.name}`
notes: `纱线入库 - ${newYarn.name}`,
operator_id: operatorId
}); });
setShowAddModal(false); setShowAddModal(false);
setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' }); setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' });
await fetchData(); await fetchData();
} catch {
} }
}; };

View File

@ -1,12 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { washingApi } from '../../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react'; import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import type { WashingPlan, WashingPlanCompletion } from '../../types'; import type { WashingPlan } from '../../types';
import { WASHING_PLAN_STATUS } from '../../utils/constants';
interface CompletedFabricItem { interface CompletedFabricItem {
id: string; id: string;
@ -37,52 +36,26 @@ export function CompletedFabric() {
const fetchCompletedFabric = async () => { const fetchCompletedFabric = async () => {
setLoading(true); setLoading(true);
// 获取已完成的水洗计划
const { data: washingPlans, error } = await supabase
.from('washing_plans')
.select(`
*,
product:product_id(product_name, color, fabric_code),
company:company_id(name)
`)
.eq('washing_factory_id', auth.company!.id)
.eq('status', WASHING_PLAN_STATUS.COMPLETED)
.order('updated_at', { ascending: false });
if (error) { try {
console.error('获取已完成坯布失败:', error); const data = await washingApi.listCompletedPlans();
} else {
// 获取完成记录详情
const planIds = (washingPlans || []).map(p => p.id);
const { data: completions } = await supabase
.from('washing_plan_completions')
.select('*')
.in('washing_plan_id', planIds);
const completionMap: Record<string, WashingPlanCompletion> = {}; const formattedItems = (data || []).map((plan: any) => ({
(completions || []).forEach(c => { id: plan.id,
completionMap[c.washing_plan_id] = c; plan_code: plan.plan_code,
}); purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商',
product_name: plan.product_name || plan.product?.product_name || '-',
const formattedItems = (washingPlans || []).map(plan => { product_color: plan.product_color || plan.product?.color || '-',
const completion = completionMap[plan.id]; fabric_code: plan.fabric_code || plan.product?.fabric_code || '-',
return { planned_meters: plan.planned_meters,
id: plan.id, actual_washed_meters: plan.completion?.actual_washed_meters || plan.actual_washed_meters || 0,
plan_code: plan.plan_code, shrinkage_rate: plan.completion?.actual_shrinkage_rate || plan.actual_shrinkage_rate || 0,
purchaser_name: (plan.company as any)?.name || '未知采购商', washing_date: plan.completion?.washing_date || plan.washing_date || '',
product_name: (plan.product as any)?.product_name || '-', rolls: plan.completion?.rolls || 0,
product_color: (plan.product as any)?.color || '-', completed_at: plan.completion?.completed_at || plan.updated_at
fabric_code: (plan.product as any)?.fabric_code || '-', }));
planned_meters: plan.planned_meters,
actual_washed_meters: completion?.actual_washed_meters || plan.actual_washed_meters || 0,
shrinkage_rate: completion?.actual_shrinkage_rate || plan.actual_shrinkage_rate || 0,
washing_date: completion?.washing_date || plan.washing_date || '',
rolls: completion?.rolls || 0,
completed_at: completion?.completed_at || plan.updated_at
};
});
setItems(formattedItems); setItems(formattedItems);
} catch {
} }
setLoading(false); setLoading(false);
}; };

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { washingApi } from '../../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, FileText, Plus, TrendingUp, Building2, ChevronRight, Hash, Target, Activity } from 'lucide-react'; import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, FileText, Plus, TrendingUp, Building2, ChevronRight, Hash, Target, Activity } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
@ -74,13 +74,8 @@ export function WashingDashboard() {
}, [auth.company]); }, [auth.company]);
const fetchPlans = async () => { const fetchPlans = async () => {
const { data: washingPlans } = await supabase const response = await washingApi.listPlans();
.from('washing_plans') setPlans(response.data || []);
.select('*')
.eq('washing_factory_id', auth.company!.id)
.order('created_at', { ascending: false });
setPlans(washingPlans || []);
setLoading(false); setLoading(false);
}; };

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { washingApi, http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, Package, Search, Filter, History, ArrowUpRight, ArrowDownRight, Boxes } from 'lucide-react'; import { ArrowLeft, Package, Search, Filter, History, ArrowUpRight, ArrowDownRight, Boxes } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
@ -39,45 +39,33 @@ export function FinishedWarehouse() {
const fetchProducts = async () => { const fetchProducts = async () => {
setLoading(true); setLoading(true);
const { data, error } = await supabase try {
.from('finished_products') const response = await washingApi.listFinishedProducts();
.select(` const productsWithDetails = (response.data || []).map((product: any) => ({
*,
warehouse:warehouse_id(name)
`)
.eq('company_id', auth.company!.id)
.order('created_at', { ascending: false });
if (error) {
console.error('获取成品失败:', error);
} else {
const productsWithDetails = (data || []).map(product => ({
...product, ...product,
warehouse_name: (product.warehouse as any)?.name warehouse_name: product.warehouse?.name || product.warehouse_name
})); }));
setProducts(productsWithDetails); setProducts(productsWithDetails);
} catch {
} }
setLoading(false); setLoading(false);
}; };
const fetchWarehouses = async () => { const fetchWarehouses = async () => {
const { data } = await supabase try {
.from('warehouses') const { data } = await http.get<Warehouse[]>('/washing/warehouses', { type: 'finished' });
.select('*') setWarehouses(data || []);
.eq('company_id', auth.company!.id) } catch {
.eq('type', 'finished'); }
setWarehouses(data || []);
}; };
const fetchInventoryRecords = async (productId: string) => { const fetchInventoryRecords = async (productId: string) => {
const { data, error } = await supabase try {
.from('finished_product_inventory_records') const { data } = await http.get<FinishedProductInventoryRecord[]>(
.select('*') `/washing/finished-products/${productId}/records`
.eq('finished_product_id', productId) );
.order('created_at', { ascending: false });
if (!error) {
setInventoryRecords(data || []); setInventoryRecords(data || []);
} catch {
} }
}; };

View File

@ -3,9 +3,8 @@ import { motion } from 'framer-motion';
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react'; import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { washingApi } from '../../api';
import type { Payment, WashingPlan } from '../../types'; import type { Payment, WashingPlan } from '../../types';
import { PAYMENT_STATUS } from '../../utils/constants';
interface PaymentWithPlan extends Payment { interface PaymentWithPlan extends Payment {
plan?: WashingPlan; plan?: WashingPlan;
@ -24,32 +23,18 @@ export function WashingPaymentPending() {
}, [auth.company]); }, [auth.company]);
const fetchPayments = async () => { const fetchPayments = async () => {
// 获取当前水洗厂作为收款方的待结款 const response = await washingApi.listPayments();
const { data: paymentData } = await supabase const paymentData = response.data || [];
.from('payments')
.select('*')
.eq('to_company_id', auth.company!.id)
.eq('status', PAYMENT_STATUS.PENDING);
if (!paymentData || paymentData.length === 0) { if (paymentData.length === 0) {
setPayments([]); setPayments([]);
setLoading(false); setLoading(false);
return; return;
} }
// 获取关联的水洗计划信息 const paymentsWithPlans = paymentData.map((p: any) => ({
const planIds = paymentData.map(p => p.plan_id);
const { data: planData } = await supabase
.from('washing_plans')
.select('*')
.in('id', planIds);
const planMap: Record<string, WashingPlan> = {};
(planData || []).forEach(p => { planMap[p.id] = p; });
const paymentsWithPlans = paymentData.map(p => ({
...p, ...p,
plan: planMap[p.plan_id] plan: p.washing_plan
})); }));
setPayments(paymentsWithPlans); setPayments(paymentsWithPlans);
@ -60,19 +45,10 @@ export function WashingPaymentPending() {
const handleConfirmPay = async (paymentId: string) => { const handleConfirmPay = async (paymentId: string) => {
setConfirming(paymentId); setConfirming(paymentId);
const now = new Date().toISOString(); try {
const { error } = await supabase await washingApi.confirmPayment(paymentId);
.from('payments')
.update({
status: PAYMENT_STATUS.COMPLETED,
paid_at: now
})
.eq('id', paymentId);
if (error) {
console.error('结款失败:', error);
} else {
await fetchPayments(); await fetchPayments();
} catch {
} }
setConfirming(null); setConfirming(null);
}; };

View File

@ -1,12 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { washingApi } from '../../api';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react'; import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
import type { WashingPlan } from '../../types'; import type { WashingPlan } from '../../types';
import { WASHING_PLAN_STATUS } from '../../utils/constants';
interface PendingFabricItem { interface PendingFabricItem {
id: string; id: string;
@ -39,33 +38,22 @@ export function PendingFabric() {
const fetchPendingFabric = async () => { const fetchPendingFabric = async () => {
setLoading(true); setLoading(true);
const { data, error } = await supabase
.from('washing_plans')
.select(`
*,
product:product_id(product_name, color, fabric_code),
company:company_id(name)
`)
.eq('washing_factory_id', auth.company!.id)
.in('status', [WASHING_PLAN_STATUS.PENDING, 'processing'])
.order('created_at', { ascending: false });
if (error) { try {
console.error('获取待处理坯布失败:', error); const data = await washingApi.listPendingPlans();
} else { const formattedItems = (data || []).map((plan: any) => ({
const formattedItems = (data || []).map(plan => ({
id: plan.id, id: plan.id,
plan_code: plan.plan_code, plan_code: plan.plan_code,
purchaser_name: (plan.company as any)?.name || '未知采购商', purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商',
product_name: (plan.product as any)?.product_name || '-', product_name: plan.product_name || plan.product?.product_name || '-',
product_color: (plan.product as any)?.color || '-', product_color: plan.product_color || plan.product?.color || '-',
fabric_code: (plan.product as any)?.fabric_code || '-', fabric_code: plan.fabric_code || plan.product?.fabric_code || '-',
planned_meters: plan.planned_meters, planned_meters: plan.planned_meters,
status: plan.status, status: plan.status,
created_at: plan.created_at created_at: plan.created_at
})); }));
setItems(formattedItems); setItems(formattedItems);
} catch {
} }
setLoading(false); setLoading(false);
}; };

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../supabase/client'; import { washingApi, http } from '../../api';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react'; import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive'; import { useResponsive } from '../../hooks/useResponsive';
@ -63,88 +63,68 @@ export function WashingPlanOverview() {
const fetchPlans = async () => { const fetchPlans = async () => {
setLoading(true); setLoading(true);
// 获取水洗计划
const { data: washingPlans, error } = await supabase
.from('washing_plans')
.select(`
*,
product:product_id(product_name, color, fabric_code),
company:company_id(name)
`)
.eq('washing_factory_id', auth.company!.id)
.order('created_at', { ascending: false });
if (error) { try {
handleError(error, { userMessage: '获取水洗计划失败' }); const response = await washingApi.listPlans();
setLoading(false); const washingPlans = response.data || [];
return;
}
if (!washingPlans || washingPlans.length === 0) { if (washingPlans.length === 0) {
setGroups([]); setGroups([]);
setLoading(false); setLoading(false);
return; return;
}
const planIds = washingPlans.map(p => p.id);
// 获取流程步骤
const { data: stepsData } = await supabase
.from('washing_process_steps')
.select('*')
.in('washing_plan_id', planIds);
// 获取操作人信息
const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[];
if (operatorIds.length > 0) {
const { data: profilesData } = await supabase
.from('profiles')
.select('id, username')
.in('id', operatorIds);
const names: Record<string, string> = {};
(profilesData || []).forEach(p => {
names[p.id] = p.username;
});
setOperatorNames(names);
}
// 按采购商分组
const grouped: Record<string, PlanGroup> = {};
washingPlans.forEach(plan => {
const purchaserId = plan.company_id;
const purchaserName = (plan.company as any)?.name || '未知采购商';
if (!grouped[purchaserId]) {
grouped[purchaserId] = {
purchaserId,
purchaserName,
plans: []
};
} }
const processSteps = (stepsData || []) const planIds = washingPlans.map(p => p.id);
.filter(s => s.washing_plan_id === plan.id)
.sort((a, b) => { let stepsData: any[] = [];
const order = ['confirm', 'start_washing', 'complete']; try {
return order.indexOf(a.step_type) - order.indexOf(b.step_type); const { data } = await http.get<any[]>('/washing/process-steps', { plan_ids: planIds.join(',') });
stepsData = data || [];
} catch {}
const operatorIds = [...new Set(stepsData.filter(s => s.operator_id).map(s => s.operator_id))] as string[];
if (operatorIds.length > 0) {
try {
const { data: profilesData } = await http.get<any[]>('/common/profiles', { ids: operatorIds.join(',') });
const names: Record<string, string> = {};
(profilesData || []).forEach(p => { names[p.id] = p.username; });
setOperatorNames(names);
} catch {}
}
const grouped: Record<string, PlanGroup> = {};
washingPlans.forEach((plan: any) => {
const purchaserId = plan.company_id;
const purchaserName = plan.purchaser_name || plan.company?.name || '未知采购商';
if (!grouped[purchaserId]) {
grouped[purchaserId] = { purchaserId, purchaserName, plans: [] };
}
const processSteps = stepsData
.filter(s => s.washing_plan_id === plan.id)
.sort((a, b) => {
const order = ['confirm', 'start_washing', 'complete'];
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
});
grouped[purchaserId].plans.push({
...plan,
product_name: plan.product_name || plan.product?.product_name,
product_color: plan.product_color || plan.product?.color,
fabric_code: plan.fabric_code || plan.product?.fabric_code,
purchaser_name: purchaserName,
processSteps
}); });
grouped[purchaserId].plans.push({
...plan,
product_name: (plan.product as any)?.product_name,
product_color: (plan.product as any)?.color,
fabric_code: (plan.product as any)?.fabric_code,
purchaser_name: purchaserName,
processSteps
}); });
});
const groupList = Object.values(grouped); const groupList = Object.values(grouped);
setGroups(groupList); setGroups(groupList);
if (groupList.length > 0) { if (groupList.length > 0) {
setExpandedGroups(new Set([groupList[0].purchaserId])); setExpandedGroups(new Set([groupList[0].purchaserId]));
}
} catch (err) {
handleError(err, { userMessage: '获取水洗计划失败' });
} }
setLoading(false); setLoading(false);
}; };
@ -201,41 +181,25 @@ export function WashingPlanOverview() {
// 确认流程步骤 // 确认流程步骤
const handleConfirmStep = async (stepId: string, stepType: string) => { const handleConfirmStep = async (stepId: string, stepType: string) => {
setConfirming(stepId); setConfirming(stepId);
const now = new Date().toISOString();
try { try {
// 更新步骤状态 const plan = groups.flatMap(g => g.plans).find(
await supabase p => p.processSteps.some(s => s.id === stepId)
.from('washing_process_steps') );
.update({ if (!plan) { setConfirming(null); return; }
status: WASHING_PLAN_STATUS.COMPLETED,
timestamp: now,
operator_id: auth.user?.id
})
.eq('id', stepId);
// 获取计划ID if (stepType === 'confirm') {
const { data: step } = await supabase await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.IN_PROGRESS });
.from('washing_process_steps') } else if (stepType === 'complete') {
.select('washing_plan_id') await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.COMPLETED });
.eq('id', stepId)
.single();
if (step) {
// 更新计划状态
if (stepType === 'confirm') {
await supabase
.from('washing_plans')
.update({ status: WASHING_PLAN_STATUS.IN_PROGRESS })
.eq('id', step.washing_plan_id);
} else if (stepType === 'complete') {
await supabase
.from('washing_plans')
.update({ status: WASHING_PLAN_STATUS.COMPLETED })
.eq('id', step.washing_plan_id);
}
} }
await http.patch(`/washing/process-steps/${stepId}`, {
status: WASHING_PLAN_STATUS.COMPLETED,
timestamp: new Date().toISOString(),
operator_id: auth.user?.id
});
await fetchPlans(); await fetchPlans();
} catch (err) { } catch (err) {
handleError(err, { userMessage: '确认失败,请重试', showAlert: true }); handleError(err, { userMessage: '确认失败,请重试', showAlert: true });
@ -259,29 +223,13 @@ export function WashingPlanOverview() {
e.preventDefault(); e.preventDefault();
if (!selectedPlan || !auth.user) return; if (!selectedPlan || !auth.user) return;
const now = new Date().toISOString();
try { try {
// 创建完成记录 await washingApi.completePlan(selectedPlan.id, {
await supabase.from('washing_plan_completions').insert({
company_id: auth.company!.id,
washing_plan_id: selectedPlan.id,
actual_washed_meters: parseFloat(completeForm.meters), actual_washed_meters: parseFloat(completeForm.meters),
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate), actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate),
rolls: parseInt(completeForm.rolls) || 0, rolls: parseInt(completeForm.rolls) || 0,
washing_date: new Date().toISOString().split('T')[0], washing_date: new Date().toISOString().split('T')[0],
completed_by: auth.user.id } as any);
});
// 更新计划状态
await supabase
.from('washing_plans')
.update({
status: WASHING_PLAN_STATUS.COMPLETED,
actual_washed_meters: parseFloat(completeForm.meters),
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate)
})
.eq('id', selectedPlan.id);
setCompleteModalOpen(false); setCompleteModalOpen(false);
setSelectedPlan(null); setSelectedPlan(null);

View File

@ -1,21 +0,0 @@
/**
* 🚨 Meoo Cloud
* This file is auto-generated by Meoo Cloud. Do not edit manually!
*/
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
export function getSupabaseUrl(): string {
return `${(window as any).MEOO_CONFIG?.meoo_app_access_url || location.origin}/sb-api`;
}
export const supabaseUrl = getSupabaseUrl();
export const supabaseAnonKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzc5MzM2NjUyLCJleHAiOjEzMjg5OTc2NjUyfQ.NQrukIeqa-IEBtFEf8YOsVK4vxbi6kQqIi-eyRsb2pA';
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
},
});

View File

@ -3,7 +3,7 @@
* 100% * 100%
*/ */
import { supabase } from '../supabase/client'; import { http } from '../api';
// ============================================================================ // ============================================================================
// 类型定义 // 类型定义
@ -150,34 +150,17 @@ export async function checkPlanQuantityConsistency(planId: string): Promise<{
inventoryTotal: number; inventoryTotal: number;
difference: number; difference: number;
}> { }> {
const { data: plan, error: planError } = await supabase const { data } = await http.get<any>(`/textile/plans/${planId}/consistency`);
.from('production_plans')
.select('completed_quantity')
.eq('id', planId)
.single();
if (planError || !plan) { if (!data) {
throw new Error(`查询计划失败: ${planError?.message}`); throw new Error(`查询计划一致性失败`);
} }
const { data: inventorySum, error: sumError } = await supabase
.from('inventory_records')
.select('quantity')
.eq('plan_id', planId);
if (sumError) {
throw new Error(`查询入库记录失败: ${sumError.message}`);
}
const inventoryTotal = inventorySum?.reduce((sum, r) => sum + (r.quantity || 0), 0) || 0;
const difference = plan.completed_quantity - inventoryTotal;
const tolerance = 0.01;
return { return {
isConsistent: Math.abs(difference) <= tolerance, isConsistent: data.is_consistent ?? Math.abs(data.difference) <= 0.01,
completedQuantity: plan.completed_quantity, completedQuantity: data.completed_quantity ?? 0,
inventoryTotal, inventoryTotal: data.inventory_total ?? 0,
difference, difference: data.difference ?? 0,
}; };
} }
@ -197,14 +180,7 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
}; };
try { try {
// 调用数据库函数进行检查 const { data } = await http.post<any[]>('/common/data-integrity-check');
const { data, error } = await supabase.rpc('run_data_integrity_check');
if (error) {
console.error('数据完整性检查失败:', error);
report.overallStatus = 'FAIL';
return report;
}
if (data && Array.isArray(data)) { if (data && Array.isArray(data)) {
for (const check of data) { for (const check of data) {
@ -264,7 +240,6 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL'; report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL';
} catch (err) { } catch (err) {
console.error('数据完整性检查异常:', err);
report.overallStatus = 'FAIL'; report.overallStatus = 'FAIL';
} }
@ -279,51 +254,17 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
* *
*/ */
export async function getRecentAuditLogs(limit: number = 100) { export async function getRecentAuditLogs(limit: number = 100) {
const { data, error } = await supabase const { data } = await http.get<any[]>('/common/audit-logs', { limit });
.from('data_audit_logs')
.select('*')
.order('changed_at', { ascending: false })
.limit(limit);
if (error) {
throw new Error(`查询审计日志失败: ${error.message}`);
}
return data; return data;
} }
/**
*
*/
export async function getFailedValidations(limit: number = 50) { export async function getFailedValidations(limit: number = 50) {
const { data, error } = await supabase const { data } = await http.get<any[]>('/common/audit-logs', { validation_passed: 'false', limit });
.from('data_audit_logs')
.select('*')
.eq('validation_passed', false)
.order('changed_at', { ascending: false })
.limit(limit);
if (error) {
throw new Error(`查询失败记录失败: ${error.message}`);
}
return data; return data;
} }
/**
*
*/
export async function getErrorRateStats(days: number = 30) { export async function getErrorRateStats(days: number = 30) {
const { data, error } = await supabase const { data } = await http.get<any[]>('/common/error-rate-stats', { days });
.from('v_error_rate_monitor')
.select('*')
.gte('check_date', new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString())
.order('check_date', { ascending: false });
if (error) {
throw new Error(`查询错误率统计失败: ${error.message}`);
}
return data; return data;
} }
@ -343,35 +284,22 @@ export async function safeInsertInventoryRecord(record: {
operator_id: string; operator_id: string;
price_per_meter?: number; price_per_meter?: number;
}) { }) {
// 第一层:前端校验
const validation = validateInventoryRecord(record); const validation = validateInventoryRecord(record);
if (!validation.isValid) { if (!validation.isValid) {
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`); throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
} }
// 第二层:数据库写入(触发器会进行二次校验) const { data } = await http.post<any>(`/textile/plans/${record.plan_id}/inventory`, record);
const { data, error } = await supabase
.from('inventory_records')
.insert(record)
.select();
if (error) { if (!data) {
throw new Error(`写入失败: ${error.message}`); throw new Error('写入失败');
} }
if (!data || data.length === 0) {
throw new Error('写入失败:可能被 RLS 策略拦截');
}
// 第三层:写入后验证
const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id); const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id);
if (!consistencyCheck.isConsistent) { if (!consistencyCheck.isConsistent) {
console.warn(
`警告:入库后数据不一致,计划 ${record.plan_id} 完成数量 ${consistencyCheck.completedQuantity},入库总和 ${consistencyCheck.inventoryTotal}`
);
} }
return data[0]; return data;
} }
/** /**
@ -386,25 +314,16 @@ export async function safeInsertPayment(payment: {
price_per_meter: number; price_per_meter: number;
status: 'pending' | 'completed'; status: 'pending' | 'completed';
}) { }) {
// 第一层:前端校验
const validation = validatePayment(payment); const validation = validatePayment(payment);
if (!validation.isValid) { if (!validation.isValid) {
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`); throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
} }
// 第二层:数据库写入 const { data } = await http.post<any>('/textile/payments', payment);
const { data, error } = await supabase
.from('payments')
.insert(payment)
.select();
if (error) { if (!data) {
throw new Error(`写入失败: ${error.message}`); throw new Error('写入失败');
} }
if (!data || data.length === 0) { return data;
throw new Error('写入失败:可能被 RLS 策略拦截');
}
return data[0];
} }

View File

@ -1,4 +1,5 @@
import { supabase } from '../supabase/client'; import { http } from '../api';
import { commonApi } from '../api';
export type NotificationType = 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system'; export type NotificationType = 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system';
@ -12,13 +13,9 @@ interface NotificationData {
content: string; content: string;
} }
/**
*
*/
export async function sendNotification(data: NotificationData): Promise<void> { export async function sendNotification(data: NotificationData): Promise<void> {
const { error } = await supabase try {
.from('notifications') await http.post('/common/notifications', {
.insert({
recipient_id: data.recipient_id, recipient_id: data.recipient_id,
sender_id: data.sender_id, sender_id: data.sender_id,
sender_company_id: data.sender_company_id, sender_company_id: data.sender_company_id,
@ -27,72 +24,31 @@ export async function sendNotification(data: NotificationData): Promise<void> {
title: data.title, title: data.title,
content: data.content content: data.content
}); });
} catch {
if (error) {
console.error('发送通知失败:', error);
} }
} }
/**
* ID
*/
export async function getPlanPurchaserUserId(planId: string): Promise<string | null> { export async function getPlanPurchaserUserId(planId: string): Promise<string | null> {
const { data: plan } = await supabase try {
.from('production_plans') const { data: plan } = await http.get<any>(`/common/plans/${planId}/purchaser`);
.select('purchaser_id, created_by') if (!plan) return null;
.eq('id', planId) return plan.user_id || null;
.maybeSingle(); } catch {
return null;
if (!plan) return null;
// 优先返回创建者,如果没有则返回公司的主账号
if (plan.created_by) {
return plan.created_by;
} }
// 获取公司的主账号
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('company_id', plan.purchaser_id)
.eq('is_master', true)
.maybeSingle();
return profile?.id || null;
} }
/**
* ID列表
*/
export async function getPlanFactoryUserIds(planId: string, factoryType?: 'textile' | 'washing'): Promise<string[]> { export async function getPlanFactoryUserIds(planId: string, factoryType?: 'textile' | 'washing'): Promise<string[]> {
let query = supabase try {
.from('plan_factories') const params: Record<string, string> = {};
.select('factory_id') if (factoryType) params.factory_type = factoryType;
.eq('plan_id', planId); const { data } = await http.get<any[]>(`/common/plans/${planId}/factory-users`, params);
return (data || []).map(p => p.id);
if (factoryType) { } catch {
query = query.eq('factory_type', factoryType); return [];
} }
const { data: factories } = await query;
if (!factories || factories.length === 0) return [];
const factoryIds = factories.map(f => f.factory_id);
// 获取这些工厂的主账号用户ID
const { data: profiles } = await supabase
.from('profiles')
.select('id')
.in('company_id', factoryIds)
.eq('is_master', true);
return (profiles || []).map(p => p.id);
} }
/**
*
*/
export async function notifyStepCompleted( export async function notifyStepCompleted(
planId: string, planId: string,
stepName: string, stepName: string,
@ -100,48 +56,17 @@ export async function notifyStepCompleted(
senderUserId: string, senderUserId: string,
details?: { quantity?: number; rolls?: number } details?: { quantity?: number; rolls?: number }
): Promise<void> { ): Promise<void> {
const purchaserId = await getPlanPurchaserUserId(planId); try {
if (!purchaserId) return; await http.post(`/common/plans/${planId}/notify/step-completed`, {
step_name: stepName,
const { data: plan } = await supabase sender_company_id: senderCompanyId,
.from('production_plans') sender_user_id: senderUserId,
.select('plan_code, product_name, color, fabric_code, color_code') details
.eq('id', planId) });
.maybeSingle(); } catch {
if (!plan) return;
const { data: senderCompany } = await supabase
.from('companies')
.select('name')
.eq('id', senderCompanyId)
.maybeSingle();
let title = `生产节点完成: ${stepName}`;
let content = `计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的"${stepName}"节点已由 ${senderCompany?.name || '工厂'} 确认完成`;
if (stepName === '坯布生产' && details?.quantity) {
title = `坯布生产: ${plan.fabric_code}`;
content = `${senderCompany?.name || '工厂'} 完成入库 ${details.quantity}`;
if (details.rolls) {
content += ` (${details.rolls}匹)`;
}
} }
await sendNotification({
recipient_id: purchaserId,
sender_id: senderUserId,
sender_company_id: senderCompanyId,
plan_id: planId,
type: 'step_completed',
title,
content
});
} }
/**
*
*/
export async function notifyInventoryIn( export async function notifyInventoryIn(
planId: string, planId: string,
quantity: number, quantity: number,
@ -152,80 +77,34 @@ export async function notifyInventoryIn(
await notifyStepCompleted(planId, '坯布入库', senderCompanyId, senderUserId, { quantity, rolls }); await notifyStepCompleted(planId, '坯布入库', senderCompanyId, senderUserId, { quantity, rolls });
} }
/**
*
*/
export async function notifyPlanCreated( export async function notifyPlanCreated(
planId: string, planId: string,
factoryIds: string[], factoryIds: string[],
senderCompanyId: string, senderCompanyId: string,
senderUserId: string senderUserId: string
): Promise<void> { ): Promise<void> {
const { data: plan } = await supabase try {
.from('production_plans') await http.post(`/common/plans/${planId}/notify/created`, {
.select('plan_code, product_name, color, fabric_code, color_code, target_quantity') factory_ids: factoryIds,
.eq('id', planId)
.maybeSingle();
if (!plan) return;
const { data: senderCompany } = await supabase
.from('companies')
.select('name')
.eq('id', senderCompanyId)
.maybeSingle();
// 获取工厂的主账号用户ID
const { data: profiles } = await supabase
.from('profiles')
.select('id')
.in('company_id', factoryIds)
.eq('is_master', true);
const notifications = (profiles || []).map(profile =>
sendNotification({
recipient_id: profile.id,
sender_id: senderUserId,
sender_company_id: senderCompanyId, sender_company_id: senderCompanyId,
plan_id: planId, sender_user_id: senderUserId
type: 'plan_created', });
title: `新计划关联: ${plan.fabric_code}`, } catch {
content: `${senderCompany?.name || '采购商'} 将您添加为计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的纺织厂,计划产量 ${plan.target_quantity}` }
})
);
await Promise.all(notifications);
} }
/**
*
*/
export async function notifyPlanUpdated( export async function notifyPlanUpdated(
planId: string, planId: string,
status: string, status: string,
senderCompanyId: string, senderCompanyId: string,
senderUserId: string senderUserId: string
): Promise<void> { ): Promise<void> {
const purchaserId = await getPlanPurchaserUserId(planId); try {
if (!purchaserId) return; await http.post(`/common/plans/${planId}/notify/updated`, {
status,
const { data: plan } = await supabase sender_company_id: senderCompanyId,
.from('production_plans') sender_user_id: senderUserId
.select('plan_code, fabric_code, color_code') });
.eq('id', planId) } catch {
.maybeSingle(); }
if (!plan) return;
const statusText = status === 'completed' ? '已完成' : status === 'producing' ? '生产中' : '待确定';
await sendNotification({
recipient_id: purchaserId,
sender_id: senderUserId,
sender_company_id: senderCompanyId,
plan_id: planId,
type: 'plan_updated',
title: `计划状态更新: ${plan.fabric_code}`,
content: `计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 状态已更新为"${statusText}"`
});
} }

View File

@ -94,6 +94,14 @@ module.exports = (env, argv) => {
allowedHosts: 'all', allowedHosts: 'all',
hot: true, hot: true,
historyApiFallback: true, historyApiFallback: true,
proxy: [
{
context: ['/api', '/ws'],
target: `http://localhost:${process.env.GATEWAY_PORT || '8080'}`,
ws: true,
changeOrigin: true,
},
],
}, },
plugins: [ plugins: [
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
@ -102,8 +110,6 @@ module.exports = (env, argv) => {
}), }),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
'process.env.VITE_SUPABASE_URL': JSON.stringify(process.env.VITE_SUPABASE_URL || ''),
'process.env.VITE_SUPABASE_ANON_KEY': JSON.stringify(process.env.VITE_SUPABASE_ANON_KEY || ''),
'__APP_ENV__': JSON.stringify(isDev ? 'development' : 'production'), '__APP_ENV__': JSON.stringify(isDev ? 'development' : 'production'),
}), }),
], ],